Browse Source

Merge branch 'master' into illyse-return

Jocelyn Delalande 7 years ago
parent
commit
07997e7a10
100 changed files with 4908 additions and 955 deletions
  1. 3 0
      .gitignore
  2. 26 8
      DEPLOYMENT.md
  3. 63 5
      EXTENDING.md
  4. 202 45
      README.md
  5. 53 0
      Vagrantfile
  6. 44 0
      coin/apps.py
  7. 77 8
      coin/billing/admin.py
  8. 1 1
      coin/billing/create_subscriptions_invoices.py
  9. 338 0
      coin/billing/management/commands/import_payments_from_csv.py
  10. 37 0
      coin/billing/management/commands/send_reminders_for_unpaid_bills.py
  11. 1 1
      coin/billing/migrations/0001_initial.py
  12. 21 0
      coin/billing/migrations/0004_auto_20161230_1803.py
  13. 20 0
      coin/billing/migrations/0005_auto_20170608_2213.py
  14. 19 0
      coin/billing/migrations/0006_auto_20170608_2305.py
  15. 19 0
      coin/billing/migrations/0007_auto_20170801_1530.py
  16. 19 0
      coin/billing/migrations/0008_auto_20170802_2021.py
  17. 54 0
      coin/billing/migrations/0009_new_billing_system_schema.py
  18. 72 0
      coin/billing/migrations/0010_new_billing_system_data.py
  19. 458 41
      coin/billing/models.py
  20. 1 1
      coin/billing/templates/admin/billing/invoice/change_form.html
  21. 9 7
      coin/billing/templates/billing/invoice.html
  22. 214 127
      coin/billing/templates/billing/invoice_pdf.html
  23. 26 14
      coin/billing/templates/billing/payment_howto.html
  24. 182 27
      coin/billing/tests.py
  25. 19 0
      coin/configuration/migrations/0004_auto_20161015_1837.py
  26. 7 0
      coin/context_processors.py
  27. 3 3
      coin/html2pdf.py
  28. 15 1
      coin/isp_database/admin.py
  29. 20 0
      coin/isp_database/migrations/0010_ispinfo_phone_number.py
  30. 21 0
      coin/isp_database/migrations/0011_auto_20170227_0029.py
  31. 21 0
      coin/isp_database/migrations/0011_auto_20170309_1247.py
  32. 21 0
      coin/isp_database/migrations/0012_auto_20170328_2257.py
  33. 15 0
      coin/isp_database/migrations/0013_merge.py
  34. 151 0
      coin/isp_database/migrations/0014_auto_20170802_2021.py
  35. 77 39
      coin/isp_database/models.py
  36. 23 0
      coin/isp_database/tests.py
  37. 118 44
      coin/members/admin.py
  38. 10 7
      coin/members/autocomplete_light_registry.py
  39. 50 4
      coin/members/forms.py
  40. 5 4
      coin/members/management/commands/call_for_membership_fees.py
  41. 46 3
      coin/members/management/commands/members_email.py
  42. 42 0
      coin/members/migrations/0013_auto_20161015_1837.py
  43. 19 0
      coin/members/migrations/0014_member_balance.py
  44. 19 0
      coin/members/migrations/0014_member_send_membership_fees_email.py
  45. 19 0
      coin/members/migrations/0015_auto_20170824_2308.py
  46. 15 0
      coin/members/migrations/0016_merge.py
  47. 25 0
      coin/members/migrations/0016_rowlevelpermission.py
  48. 15 0
      coin/members/migrations/0017_merge.py
  49. 107 16
      coin/members/models.py
  50. 21 5
      coin/members/templates/members/contact.html
  51. 78 31
      coin/members/templates/members/detail.html
  52. 7 6
      coin/members/templates/members/emails/call_for_membership_fees.html
  53. 29 13
      coin/members/templates/members/index.html
  54. 35 1
      coin/members/templates/members/invoices.html
  55. 19 19
      coin/members/templates/members/registration/login.html
  56. 1 1
      coin/members/templates/members/registration/password_reset_form.html
  57. 109 72
      coin/members/tests.py
  58. 28 6
      coin/members/views.py
  59. 33 7
      coin/offers/admin.py
  60. 44 0
      coin/offers/management/commands/offer_subscriptions_count.py
  61. 20 0
      coin/offers/migrations/0006_offer_reference.py
  62. 19 0
      coin/offers/migrations/0007_offersubscription_comments.py
  63. 53 1
      coin/offers/models.py
  64. 2 1
      coin/offers/urls.py
  65. 34 2
      coin/offers/views.py
  66. 2 2
      coin/resources/migrations/0001_initial.py
  67. 1 1
      coin/resources/migrations/0003_auto_20150203_1043.py
  68. 15 17
      coin/resources/models.py
  69. 4 246
      coin/settings.py
  70. 275 0
      coin/settings_base.py
  71. 7 0
      coin/settings_local.example-illyse.py
  72. 11 0
      coin/settings_test.py
  73. 211 107
      coin/static/css/local.css
  74. 2 1
      coin/templates/base.html
  75. 6 2
      coin/templates/menu_items.html
  76. 16 6
      coin/urls.py
  77. 38 2
      coin/utils.py
  78. 8 0
      coin/validation.py
  79. 35 0
      contrib/ansible/coin-customizations/django_local_settings.py.j2
  80. 20 0
      contrib/ansible/coin-customizations/lighttpd-coin.conf.j2
  81. 6 0
      contrib/ansible/coin-customizations/supervisor-coin.conf.j2
  82. 32 0
      contrib/ansible/coin-customizations/wsgi.py.j2
  83. 127 0
      contrib/ansible/si.yml
  84. 154 0
      doc/user/permissions.md
  85. 1 0
      hardware_provisioning/__init__.py
  86. 192 0
      hardware_provisioning/admin.py
  87. 11 0
      hardware_provisioning/app.py
  88. 39 0
      hardware_provisioning/fields.py
  89. 54 0
      hardware_provisioning/forms.py
  90. 70 0
      hardware_provisioning/migrations/0001_initial.py
  91. 32 0
      hardware_provisioning/migrations/0002_auto_20150625_2313.py
  92. 15 0
      hardware_provisioning/migrations/0003_auto_20160405_1812.py
  93. 21 0
      hardware_provisioning/migrations/0004_auto_20160405_1816.py
  94. 27 0
      hardware_provisioning/migrations/0005_auto_20160405_1841.py
  95. 27 0
      hardware_provisioning/migrations/0006_storage.py
  96. 20 0
      hardware_provisioning/migrations/0007_item_storage.py
  97. 15 0
      hardware_provisioning/migrations/0008_auto_20160405_2234.py
  98. 20 0
      hardware_provisioning/migrations/0009_auto_20160405_2236.py
  99. 20 0
      hardware_provisioning/migrations/0010_auto_20160405_2237.py
  100. 0 0
      hardware_provisioning/migrations/0011_auto_20161028_2009.py

+ 3 - 0
.gitignore

@@ -12,3 +12,6 @@ coin/settings_local.py
 /smedia
 .idea
 venv
+/coin.sqlite3
+.cache/
+.vagrant/

+ 26 - 8
DEPLOYMENT.md

@@ -1,5 +1,13 @@
-For production deployment, it is recommended to use a reverse proxy
-setup, for instance using gunicorn.
+Before deploying in production, you should read carefully the django
+deployment checklist :
+
+  https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/
+
+It is highly recommended to set the TEMPATE_DEBUG and DEBUG variables
+to False when deploying in production.
+
+For production deployment, it is also recommended to use a reverse
+proxy setup, for instance using gunicorn.
 
 This is because the access to invoices (PDF) is a bit special: they
 are served by the web server for efficiency, but django itself handles
@@ -9,6 +17,9 @@ authorisation.  This needs special support from the web server
 The following assumes Debian wheezy, with either Apache or Nginx as
 frontend.
 
+For the complete deployment configuration used by Illyse, see:
+
+  https://www.illyse.org/projects/ils-si/wiki/Mise_en_place_production
 
 ## Gunicorn configuration
 
@@ -74,16 +85,21 @@ get any traceback.
             proxy_redirect off;
             proxy_set_header Host $http_host;
             proxy_set_header X-Real-IP $remote_addr;
-    
+  
+            location /static/ {
+                    alias /home/coin/coin/coin/static/;
+            }
+            # Invoices, see SENDFILE_* options in coin
+            location /protected/ {
+                    internal;
+                    alias /home/coin/coin/smedia/;
+            }
             location / {
                     proxy_pass http://localhost:8484;
             }
     }
 
 
-TODO: sendfile support for invoices
-
-
 ## Apache configuration
 
     <VirtualHost *:80>
@@ -128,7 +144,9 @@ TODO: sendfile support for invoices
     	SSLEngine On
             SSLCertificateFile    /etc/ssl/certs/illyse-coin-cert.pem
             SSLCertificateKeyFile /etc/ssl/private/illyse-coin-privkey.pem
+
+	# Directly send invoices, avoid Django to do it
+	XSendFile On
+	XSendFilePath /home/myuser/coin/smedia/
     
     </VirtualHost>
-
-TODO: sendfile support for invoices

+ 63 - 5
EXTENDING.md

@@ -135,12 +135,70 @@ Here is an example URL pattern to be used in your `urls.py`:
 
     url(r'^(?P<id>\d+)$', VPNView.as_view(template_name="vpn/vpn.html"), name="details")
 
-Note that this pattern **must** be called "details".  The global `urls.py`
-should contain a pattern of the form:
+Note that this pattern **must** be called "details".
+Of course, you can add as many additional views as you want.
+
+URLs
+----
+
+App views URLs are pluggable, you only have to tell your app to declare its
+URLs. Then its URLs will be available under `<app_name>/<view_name>` (as long s
+your app is listed in `INSTALLED_APPS`).
+
+To do so :
+
+1. Create a `<app_name>/apps.py` like (important part is inheriting
+   `coin.apps.AppURLs`) :
+
+    from django.apps import AppConfig
+    import coin.apps
 
-    url(r'^vpn/', include('coin.vpn.urls', namespace='vpn'))
+    class MyAppConfig(AppConfig, coin.apps.AppURLs):
+        name = 'myapp'
+        verbose_name = "Fruity app !"
 
-where the value of "namespace" is the URL namespace defined in your
-original model (see above).
+2. Edit a `<app_dir>/__init__.py` :
+
+    default_app_config = 'coin.myapp.apps.MyAppConfig
+
+
+Optionaly, you can customize which URLs are plugged and to which prefix via the
+`exported_urlpatterns` var on your config class as a list of
+`<prefix>,<urlpatterns>` :
+
+        class MyAppConfig(AppConfig, coin.apps.AppURLS):
+            name = 'my_app'
+            exported_urlpatterns = [('coolapp', 'my_app.cool_urls')]
 
 Of course, you can add as many additional views as you want.
+
+Templates
+---------
+
+app-specific templates and static files should be placed according to
+[the reusable apps layout](https://docs.djangoproject.com/en/1.9/intro/reusable-apps/#your-project-and-your-reusable-app).
+
+- E.g. app-specific css : *<app folder>/static/<app name>/css/local.css*
+- E.g. app-specific template : *<app folder>/templates/<app name>/base.html*
+
+In order to load app-specific *CSS* and *JavaScript*, you may want to use the
+*extra_css* and *extra_js* template blocks, defined in main *base.html*.
+
+Example:
+
+    {% extends "base.html" %}
+    {% block extra_css %}<link rel="stylesheet" href="{% static "myapp/css/local.css" %}">{% endblock %}
+    {% block extra_js %}<script>alert("So extra !");</script>{% endblock %}
+
+
+Menu items
+----------
+
+If you want to add your own links to the main coin menu (left sidebar); edit
+the *coin/templates/menu_items.html* adding a conditional like that :
+
+    {% if 'my_app' in INSTALLED_APPS %}
+    <li></li>
+    {% endif %}
+
+… That way, your links will display only if your app is enabled.

+ 202 - 45
README.md

@@ -15,29 +15,9 @@ Coin currently only works with python2, because `python-ldap` is (as of
 
 The project page (issue, wiki, etc) is here:
 
-  https://www.illyse.org/projects/ils-si/
-
-The code is available at:
-
-  git://git.illyse.org:coin
-
-A mirror of the code, with a web interface, is also 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.
+  https://code.ffdn.org/FFDN/coin/
 
+A mailing list is available, for both users and developers: https://listes.illyse.org/wws/info/si
 
 Extending Coin
 ==============
@@ -57,26 +37,29 @@ replace the `virtualenv` command with `virtualenv2` in the following.
 
 To create the virtualenv (the first time):
 
-  virtualenv ~/tmp/venv-illyse
+    virtualenv ./venv
 
 
 To activate the virtualenv (you need to do this each time you work on
 the project):
 
-  . ~/tmp/venv-illyse/bin/activate
+    source ./venv/bin/activate
 
 
 Install dependencies. On Debian, you will probably need the
-`python-dev`, `python-pip`, `libldap-dev`, `libpq-dev` and `libsasl2-dev`
-packages.
+`python-dev`, `python-pip`, `libldap-dev`, `libpq-dev`, `libsasl2-dev`,
+and `libjpeg-dev` packages.
+
+    sudo apt-get install python-dev python-pip libldap2-dev libpq-dev libsasl2-dev libjpeg-dev libxml2-dev libxslt1-dev libffi-dev
 
-  sudo apt-get install python-dev python-pip libldap2-dev libpq-dev libsasl2-dev
+You need a recent *pip* for the installation of dependencies to work. If you
+don't meet that requirement (Ubuntu trusty does not), run:
 
-Then run:
+    pip install "pip>=1.5.6"
 
-  pip install -r requirements.txt
+In any case, you then need to install coin python dependencies:
 
-You may experience problems with SSL certificates du to self-signed cert used by code.ffdn.org. You can temporarily disable certificate verification in git : git config --global http.sslVerify false
+    pip install -r requirements.txt
 
 You should now be able to run `python manage.py` (within the
 virtualenv, obviously) without error.
@@ -91,33 +74,70 @@ settings:
     echo '# -*- coding: utf-8 -*-' > coin/settings_local.py
     echo 'DEBUG = TEMPLATE_DEBUG = True' >> coin/settings_local.py
 
-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:
+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:
 
 - 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
+To use sqlite instead of PostgreSQL, you have
+to [override local settings](#settings) with someting like:
+
+```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
+    },
+}
+```
+
+### 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
+    python manage.py loaddata offers ip_pool # skip this if you don't use PostgreSQL
 
 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
@@ -135,6 +155,29 @@ At this point, Django should run correctly:
     python manage.py runserver
 
 
+Running tests
+-------------
+
+There is a set of unit tests you can run with :
+
+    DJANGO_SETTINGS_MODULE=coin.settings_test ./manage.py test
+
+LDAP-related tests are disabled by default.
+
+Setup LDAP parameters and activate LDAP in settings to make the LDAP tests
+run.
+
+### With pytest
+
+Setup:
+
+    pip install pytest-django
+
+Run:
+
+    pytest
+
+
 Available commands
 ==================
 
@@ -144,7 +187,8 @@ Some useful administration commands are available via `manage.py`.
 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.
+membership fee, his or her address will still show up in this list. More
+filters are available, see the command's help for more details.
 
 `python manage.py charge_subscriptions`: generate invoices (including a
 PDF version) for each subscriber.  You probably want to run this command
@@ -155,6 +199,12 @@ 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.
 
+`python manage.py offer_subscriptions_count`: Returns subscription count grouped
+by offer type.
+
+`python manage.py import_payments_from_csv`: Import a CSV from a bank and match
+payments with services and/or members. At the moment, this is quite specific to
+ARN workflow
 
 Configuration
 =============
@@ -181,7 +231,18 @@ 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.
+to display on the home page, and so on.  Lastly, it is possible to override
+all Coin templates (i.e. for user views and emails), see below.
+
+Sending emails
+--------------
+
+Coin sends mails, you might want to customize the sender address:
+
+- `DEFAULT_FROM_EMAIL` app-wide setting
+- *administrative email* field, from *ISPDatabase* app, if set, will take
+  precedence over `DEFAULT_FROM_EMAIL` for administrative emails (welcome email
+  and membership fees), will take precedence (if filled).
 
 Cron tasks
 ----------
@@ -204,11 +265,107 @@ can set an email address in the crontab:
 
     MAILTO=tresorier@myisp.fr
 
+Customizing templates
+---------------------
+
+You may want to override some of the (HTML or email) templates to better suit
+your structure needs.
+
+Coin allows you to have a folder of custom templates that will contain your
+templates, which gets loaded prior to coin builtins.
+
+With this method, several templates can be overridden:
+
+- template files in `coin/templates/`
+- template files in `coin/<app_name>/templates/` for all active applications
+
+### Do once (setup)
+
+- Create a folder dedicated to your custom templates (*hint: outside of coin git
+  tree is better*).
+- Register that folder in the `EXTRA_TEMPLATE_DIRS` settings.
+
+For instance, in `settings_local.py`:
+
+    EXTRA_TEMPLATE_DIRS = ('/home/coin/my-folder/templates',)
+
+### For each template you want to override
+
+Copy the template you want to override to the right place in your custom
+ folder (that's the hard part, see the example).
+
+*Example*
+
+Say we want to override the template located at
+`coin/members/templates/members/emails/call_for_membership_fees.html` and we
+set `EXTRA_TEMPLATE_DIRS = ('/home/coin/my-folder/templates',)` in settings.
+
+Then  make a copy of the template file (and customize it) at
+`/home/coin/my-folder/templates/members/emails/call_for_membership_fees.html`
+
+Good to go :-)
+
+Using optional apps
+-------------------
+
+Some apps are not enabled by default :
+
+- *vpn*: Management of OpenVPN subscription and credentials through LDAP
+- *simple_dsl*: Simple DSL subscriptions, without handling
+   any authentication backend or user configuration ("marque blanche")
+- *hardware_provisioning* : Self-service app to manage hardware inventory,
+  hardware lent to members or in different stock sites.
+
+You can enable them using the `EXTRA_INSTALLED_APPS` setting.
+E.g. in `settings_local.py`:
+
+    EXTRA_INSTALLED_APPS = (
+        'vpn',
+    )
+
+If you enable an extra-app after initial installation, make sure to sync database :
+
+    ./manage.py migrate
+
+*nb: extra apps are loaded after the builtin apps.*
+
+
+Settings
+========
+
+List of available settings in your `settings_local.py` file.
+
+- `EXTRA_INSTALLED_APPS`: See *Customizing app list*
+- `EXTRA_TEMPLATE_DIRS`: See *Customizing templates*
+- `LDAP_ACTIVATE`: See *LDAP*
+- `MEMBER_MEMBERSHIP_INFO_URL`: Link to a page with information on how to become a member or pay the membership fee
+- `SUBSCRIPTION_REFERENCE`: Pattern used to display a unique reference for any subscription. Helpful for bank wire transfer identification
+- `PAYMENT_DELAY`: Payment delay in days for issued invoices ( default is 30 days which is the default in french law)
+- `MEMBER_CAN_EDIT_PROFILE`: Allow members to edit their profiles
+
+Accounting logs
+---------------
+
+To log 'accounting-related operations' (creation/update of invoice, payment
+and member balance) to a specific file, add the following to settings_local.py :
+
+```
+from settings_base import *
+LOGGING["formatters"]["verbose"] = {'format': "%(asctime)s - %(name)s - %(levelname)s - %(message)s"}
+LOGGING["handlers"]["coin_accounting"] = {
+    'level':'INFO',
+    'class':'logging.handlers.RotatingFileHandler',
+    'formatter': 'verbose',
+    'filename': '/var/log/coin/accounting.log',
+    'maxBytes': 1024*1024*15, # 15MB
+    'backupCount': 10,
+}
+LOGGING["loggers"]["coin.billing"]["handlers"] = [ 'coin_accounting' ]
+```
+
 More information
 ================
 
-For the rest of the setup (database, LDAP), see
-
-  https://www.illyse.org/projects/ils-si/wiki/Mise_en_place_environnement_de_dev
+For the rest of the setup (database, LDAP), see https://doc.illyse.net/projects/ils-si/wiki/Mise_en_place_environnement_de_dev
 
 For real production deployment, see file `DEPLOYMENT.md`.

+ 53 - 0
Vagrantfile

@@ -0,0 +1,53 @@
+# -*- 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

+ 44 - 0
coin/apps.py

@@ -0,0 +1,44 @@
+from os.path import basename
+
+import six
+from django.apps import apps
+
+from .utils import rstrip_str
+
+
+class AppURLsMeta(type):
+    def __init__(cls, name, bases, data):
+        if len(bases) > 1: # execute only on leaf class
+            exported_urlpatterns = data.pop('exported_urlpatterns', None)
+
+            if exported_urlpatterns:
+                cls.exported_urlpatterns = exported_urlpatterns
+            else:
+                # Default : sets
+                #   exported_urlpatterns = [(<app_name>, <app_url_module>)]
+                current_path = '.' + rstrip_str(rstrip_str(basename(__file__), '.pyc'), '.py')
+                url_module = rstrip_str(cls.__module__, current_path) + '.urls'
+                cls.exported_urlpatterns = [(data['name'], url_module)]
+
+            cls.urlprefix = data.pop('urlprefix', None)
+
+
+class AppURLs(six.with_metaclass(AppURLsMeta)):
+    """ App Mixxin to allow an application to expose pluggable urls
+
+    That's to say, URLs which will be added automatically to the projet
+    urlpatterns.
+
+    You can just make your app inherit from AppURLs, yous app urls.py will be
+    picked and wired on project urlpatterns, using the app name as prefix.
+
+    You can also customize which urlpatterns your app exposes by setting the
+    `exported_urlpattens` on your AppConfig class as list of `<prefix>,<urlpatterns>`
+
+    E.g:
+
+        class MyAppConfig(AppConfig, coin.apps.AppURLS):
+            name = 'my_app'
+            exported_urlpatterns = [('my_app', 'myapp.cool_urls')]
+    """
+    pass

+ 77 - 8
coin/billing/admin.py

@@ -5,10 +5,10 @@ from django.contrib import admin
 from django.contrib import messages
 from django.http import HttpResponseRedirect
 from django.conf.urls import url
-from django.contrib.admin.util import flatten_fieldsets
+from django.contrib.admin.utils import flatten_fieldsets
 
 from coin.filtering_queryset import LimitedAdminInlineMixin
-from coin.billing.models import Invoice, InvoiceDetail, Payment
+from coin.billing.models import Invoice, InvoiceDetail, Payment, PaymentAllocation
 from coin.billing.utils import get_invoice_from_id_or_number
 from django.core.urlresolvers import reverse
 import autocomplete_light
@@ -64,21 +64,43 @@ class InvoiceDetailInlineReadOnly(admin.StackedInline):
         return result
 
 
-class PaymentInline(admin.StackedInline):
+class PaymentAllocatedReadOnly(admin.TabularInline):
+    model = PaymentAllocation
+    extra = 0
+    fields = ("payment", "amount")
+    readonly_fields = ("payment", "amount")
+    verbose_name = None
+    verbose_name_plural = "Paiement alloués"
+
+    def has_add_permission(self, request, obj=None):
+        return False
+
+    def has_delete_permission(self, request, obj=None):
+        return False
+
+
+class PaymentInlineAdd(admin.StackedInline):
     model = Payment
     extra = 0
     fields = (('date', 'payment_mean', 'amount'),)
+    can_delete = False
+
+    verbose_name_plural = "Ajouter des paiements"
+
+    def has_change_permission(self, request):
+        return False
 
 
 class InvoiceAdmin(admin.ModelAdmin):
-    list_display = ('number', 'date', 'status', 'amount', 'member', 'validated')
+    list_display = ('number', 'date', 'status', 'amount', 'member',
+                    'validated')
     list_display_links = ('number', 'date')
     fields = (('number', 'date', 'status'),
               ('date_due'),
               ('member'),
               ('amount', 'amount_paid'),
               ('validated', 'pdf'))
-    readonly_fields = ('amount', 'amount_paid', 'validated', 'pdf')
+    readonly_fields = ('amount', 'amount_paid', 'validated', 'pdf', 'number')
     form = autocomplete_light.modelform_factory(Invoice, fields='__all__')
 
     def get_readonly_fields(self, request, obj=None):
@@ -111,7 +133,10 @@ class InvoiceAdmin(admin.ModelAdmin):
             else:
                 inlines = [InvoiceDetailInline]
 
-            inlines += [PaymentInline]
+            if obj.validated:
+                inlines += [PaymentAllocatedReadOnly]
+                if obj.status == "open":
+                    inlines += [PaymentInlineAdd]
 
         for inline_class in inlines:
             inline = inline_class(self.model, self.admin_site)
@@ -144,11 +169,16 @@ class InvoiceAdmin(admin.ModelAdmin):
         Vue appelée lorsque l'admin souhaite valider une facture et
         générer son pdf
         """
+
         # TODO : Add better perm here
         if request.user.is_superuser:
             invoice = get_invoice_from_id_or_number(id)
-            invoice.validate()
-            messages.success(request, 'La facture a été validée.')
+            if invoice.amount() == 0:
+                messages.error(request, 'Une facture validée ne peut pas avoir'
+                                        ' un total de 0€.')
+            else:
+                invoice.validate()
+                messages.success(request, 'La facture a été validée.')
         else:
             messages.error(
                 request, 'Vous n\'avez pas l\'autorisation de valider '
@@ -158,4 +188,43 @@ class InvoiceAdmin(admin.ModelAdmin):
                                             args=(id,)))
 
 
+class PaymentAllocationInlineReadOnly(admin.TabularInline):
+    model = PaymentAllocation
+    extra = 0
+    fields = ("invoice", "amount")
+    readonly_fields = ("invoice", "amount")
+    verbose_name = None
+    verbose_name_plural = "Alloué à"
+
+    def has_add_permission(self, request, obj=None):
+        return False
+
+    def has_delete_permission(self, request, obj=None):
+        return False
+
+
+class PaymentAdmin(admin.ModelAdmin):
+
+    list_display = ('__unicode__', 'member', 'payment_mean', 'amount', 'date',
+                    'amount_already_allocated', 'label')
+    list_display_links = ()
+    fields = (('member'),
+              ('amount', 'payment_mean', 'date', 'label'),
+              ('amount_already_allocated'))
+    readonly_fields = ('amount_already_allocated', 'label')
+    form = autocomplete_light.modelform_factory(Payment, fields='__all__')
+
+    def get_readonly_fields(self, request, obj=None):
+
+        # If payment already started to be allocated or already have a member
+        if obj and (obj.amount_already_allocated != 0 or obj.member != None):
+            # All fields are readonly
+            return flatten_fieldsets(self.declared_fieldsets)
+        else:
+            return self.readonly_fields
+
+    def get_inline_instances(self, request, obj=None):
+        return [PaymentAllocationInlineReadOnly(self.model, self.admin_site)]
+
 admin.site.register(Invoice, InvoiceAdmin)
+admin.site.register(Payment, PaymentAdmin)

+ 1 - 1
coin/billing/create_subscriptions_invoices.py

@@ -36,7 +36,7 @@ def create_all_members_invoices_for_a_period(date=None):
 @transaction.atomic
 def create_member_invoice_for_a_period(member, date):
     """
-    Créé si necessaire une facture pour un membre en prenant la date passée
+    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
     ou None si aucune facture n'était necessaire.
     """

+ 338 - 0
coin/billing/management/commands/import_payments_from_csv.py

@@ -0,0 +1,338 @@
+# -*- coding: utf-8 -*-
+"""
+Import payments from a CSV file from a bank.  The payments will automatically be
+parsed, and there'll be an attempt to automatically match payments with members.
+
+The matching is performed using the label of the payment.
+- First, try to find a string such as 'ID-42' where 42 is the member's ID
+- Second (if no ID found), try to find a member username (with no ambiguity with
+  respect to other usernames)
+- Third (if no username found), try to find a member family name (with no
+  ambiguity with respect to other family name)
+
+This script will check if a payment has already been registered with same
+properies (date, label, price) to avoid creating duplicate payments inside coin.
+
+By default, only a dry-run is perfomed to let you see what will happen ! You
+should run this command with --commit if you agree with the dry-run.
+"""
+
+from __future__ import unicode_literals
+
+# Standard python libs
+import csv
+import datetime
+import json
+import logging
+import os
+import re
+
+# Django specific imports
+from argparse import RawTextHelpFormatter
+from django.core.management.base import BaseCommand, CommandError
+
+# Coin specific imports
+from coin.members.models import Member
+from coin.billing.models import Payment
+
+# Parser / import / matcher configuration
+
+# The CSV delimiter
+DELIMITER=str(';')
+# The date format in the CSV
+DATE_FORMAT="%d/%m/%Y"
+# The default regex used to match the label of a payment with a member ID
+ID_REGEX=r"(?i)(\b|_)ID[\s\-\_\/]*(\d+)(\b|_)"
+# If the label of the payment contains one of these, the payment won't be
+# matched to a member when importing it.
+KEYWORDS_TO_NOTMATCH=[ "DON", "MECENAT", "REM CHQ" ]
+
+class Command(BaseCommand):
+
+    help = __doc__
+
+    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(
+            'filename',
+            type=str,
+            help="The CSV filename to be parsed"
+        )
+
+        parser.add_argument(
+            '--commit',
+            action='store_true',
+            dest='commit',
+            default=False,
+            help='Agree with the proposed change and commit them'
+        )
+
+
+    def handle(self, *args, **options):
+
+        assert options["filename"] != ""
+
+        if not os.path.isfile(options["filename"]):
+            raise CommandError("This file does not exists.")
+
+        payments = self.convert_csv_to_dicts(self.clean_csv(self.load_csv(options["filename"])))
+
+        payments = self.try_to_match_payment_with_members(payments)
+        new_payments = self.filter_already_known_payments(payments)
+        new_payments = self.unmatch_payment_with_keywords(new_payments)
+
+        number_of_already_known_payments = len(payments)-len(new_payments)
+        number_of_new_payments = len(new_payments)
+
+        if (number_of_new_payments > 0) :
+            print("======================================================")
+            print("   > New payments found")
+            print(json.dumps(new_payments, indent=4, separators=(',', ': ')))
+        print("======================================================")
+        print("Number of already known payments found : " + str(number_of_already_known_payments))
+        print("Number of new payments found           : " + str(number_of_new_payments))
+        print("Number of new payments matched         : " + str(len([p for p in new_payments if     p["member_matched"]])))
+        print("Number of payments not matched         : " + str(len([p for p in new_payments if not p["member_matched"]])))
+        print("======================================================")
+
+        if number_of_new_payments == 0:
+            print("Nothing to do, everything looks up to date !")
+            return
+
+        if not options["commit"]:
+            print("Please carefully review the matches, then if everything \n" \
+                  "looks alright, use --commit to register these new payments.")
+        else:
+            self.add_new_payments(new_payments)
+
+
+    def is_date(self, text):
+        try:
+            datetime.datetime.strptime(text, DATE_FORMAT)
+            return True
+        except ValueError:
+            return False
+
+
+    def is_money_amount(self, text):
+        try:
+            float(text.replace(",","."))
+            return True
+        except ValueError:
+            return False
+
+
+    def load_csv(self, filename):
+        with open(filename, "r") as f:
+            return list(csv.reader(f, delimiter=DELIMITER))
+
+
+    def clean_csv(self, data):
+
+        output = []
+
+        for i, row in enumerate(data):
+
+            for j in range(len(row)):
+                row[j] = row[j].decode('utf-8')
+
+            if len(row) < 4:
+                continue
+
+            if not self.is_date(row[0]):
+                logging.warning("Ignoring the following row (bad format for date in the first column) :")
+                logging.warning(str(row))
+                continue
+
+            if self.is_money_amount(row[2]):
+                logging.warning("Ignoring row %s (not a payment)" % str(i))
+                logging.warning(str(row))
+                continue
+
+            if not self.is_money_amount(row[3]):
+                logging.warning("Ignoring the following row (bad format for money amount in colun three) :")
+                logging.warning(str(row))
+                continue
+
+            # Clean the date
+            row[0] = datetime.datetime.strptime(row[0], DATE_FORMAT).strftime("%Y-%m-%d")
+
+            # Clean the label ...
+            row[4] = row[4].replace('\r', ' ')
+            row[4] = row[4].replace('\n', ' ')
+
+            output.append(row)
+
+        return output
+
+
+    def convert_csv_to_dicts(self, data):
+
+        output = []
+
+        for row in data:
+            payment = {}
+
+            payment["date"] = row[0]
+            payment["label"] = row[4]
+            payment["amount"] = float(row[3].replace(",","."))
+
+            output.append(payment)
+
+        return output
+
+
+    def try_to_match_payment_with_members(self, payments):
+
+        members = Member.objects.filter(status="member")
+
+        idregex = re.compile(ID_REGEX)
+
+        for payment in payments:
+
+            payment_label = payment["label"]
+
+            # First, attempt to match the member ID
+            idmatches = idregex.findall(payment_label)
+            if len(idmatches) == 1:
+                i = int(idmatches[0][1])
+                member_matches = [ member.username for member in members if member.pk==i ]
+                if len(member_matches) == 1:
+                    payment["member_matched"] = member_matches[0]
+                    #print("Matched by ID to "+member_matches[0])
+                    continue
+
+
+            # Second, attempt to find the username
+            usernamematch = None
+            for member in members:
+                matches = re.compile(r"(?i)(\b|_)"+re.escape(member.username)+r"(\b|_)") \
+                            .findall(payment_label)
+                # If not found, try next
+                if len(matches) == 0:
+                    continue
+                # If we already had a match, abort the whole search because we
+                # have multiple usernames matched !
+                if usernamematch != None:
+                    usernamematch = None
+                    break
+
+                usernamematch = member.username
+
+            if usernamematch != None:
+                payment["member_matched"] = usernamematch
+                #print("Matched by username to "+usernamematch)
+                continue
+
+
+            # Third, attempt to match by family name
+            familynamematch = None
+            for member in members:
+                if member.last_name == "":
+                    continue
+
+                matches = re.compile(r"(?i)(\b|_)"+re.escape(str(member.last_name))+r"(\b|_)") \
+                            .findall(payment_label)
+                # If not found, try next
+                if len(matches) == 0:
+                    continue
+                # If this familyname was matched several time, abort the whole search
+                if len(matches) > 1:
+                    familynamematch = None
+                    break
+                # If we already had a match, abort the whole search because we
+                # have multiple familynames matched !
+                if familynamematch != None:
+                    familynamematch = None
+                    break
+
+                familynamematch = str(member.last_name)
+                usernamematch = str(member.username)
+
+            if familynamematch != None:
+                payment["member_matched"] = usernamematch
+                #print("Matched by familyname to "+familynamematch)
+                continue
+
+            #print("Could not match")
+            payment["member_matched"] = None
+
+        return payments
+
+
+    def unmatch_payment_with_keywords(self, payments):
+
+        matchers = {}
+        for keyword in KEYWORDS_TO_NOTMATCH:
+            matchers[keyword] = re.compile(r"(?i)(\b|_|-)"+re.escape(keyword)+r"(\b|_|-)")
+
+        for i, payment in enumerate(payments):
+
+            # If no match found, don't filter anyway
+            if payment["member_matched"] == None:
+                continue
+
+            for keyword, matcher in matchers.items():
+                matches = matcher.findall(payment["label"])
+
+                # If not found, try next
+                if len(matches) == 0:
+                    continue
+
+                print("Ignoring possible match for payment '%s' because " \
+                      "it contains the keyword %s"                        \
+                      % (payment["label"], keyword))
+                payments[i]["member_matched"] = None
+
+                break
+
+        return payments
+
+    def filter_already_known_payments(self, payments):
+
+        new_payments = []
+
+        known_payments = Payment.objects.all()
+
+        for payment in payments:
+
+            found_match = False
+            for known_payment in known_payments:
+
+                if  (str(known_payment.date) == payment["date"].encode('utf-8')) \
+                and (known_payment.label == payment["label"]) \
+                and (float(known_payment.amount) == float(payment["amount"])):
+                    found_match = True
+                    break
+
+            if not found_match:
+                new_payments.append(payment)
+
+        return new_payments
+
+
+    def add_new_payments(self, new_payments):
+
+        for new_payment in new_payments:
+
+            # Get the member if there's a member matched
+            member = None
+            if new_payment["member_matched"]:
+                member = Member.objects.filter(username=new_payment["member_matched"])
+                assert len(member) == 1
+                member = member[0]
+
+            print("Adding new payment : ")
+            print(new_payment)
+
+            # Create the payment
+            payment = Payment.objects.create(amount=float(new_payment["amount"]),
+                                             label=new_payment["label"],
+                                             date=new_payment["date"],
+                                             member=member)
+

+ 37 - 0
coin/billing/management/commands/send_reminders_for_unpaid_bills.py

@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+# Standard python libs
+import logging
+
+# Django specific imports
+from argparse import RawTextHelpFormatter
+from django.core.management.base import BaseCommand, CommandError
+
+# Coin specific imports
+from coin.billing.models import Invoice
+
+
+class Command(BaseCommand):
+
+    help = """
+Send a reminder to members for invoices which are due and not paid since a few
+weeks.
+"""
+
+    def create_parser(self, *args, **kwargs):
+        parser = super(Command, self).create_parser(*args, **kwargs)
+        parser.formatter_class = RawTextHelpFormatter
+        return parser
+
+    def handle(self, *args, **options):
+
+        invoices = Invoice.objects.filter(status="open")
+
+        for invoice in invoices:
+
+            if not invoice.reminder_needed():
+                continue
+
+            invoice.send_reminder(auto=True)
+

+ 1 - 1
coin/billing/migrations/0001_initial.py

@@ -19,7 +19,7 @@ class Migration(migrations.Migration):
             fields=[
                 ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
                 ('validated', models.BooleanField(default=False, verbose_name='valid\xe9e')),
-                ('number', models.CharField(default=coin.billing.models.next_invoice_number, unique=True, max_length=25, verbose_name='num\xe9ro')),
+                ('number', models.CharField(unique=True, max_length=25, verbose_name='num\xe9ro')),
                 ('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")),

+ 21 - 0
coin/billing/migrations/0004_auto_20161230_1803.py

@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+import datetime
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('billing', '0003_auto_20150221_2226'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='invoice',
+            name='date',
+            field=models.DateField(default=datetime.date.today, help_text='Cette date sera d\xe9finie \xe0 la date de validation dans la facture finale', null=True, verbose_name='date'),
+            preserve_default=True,
+        ),
+    ]

+ 20 - 0
coin/billing/migrations/0005_auto_20170608_2213.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import coin.utils
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('billing', '0004_auto_20161230_1803'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='invoice',
+            name='date_due',
+            field=models.DateField(default=coin.utils.end_of_month, help_text='Le d\xe9lai de paiement sera fix\xe9 \xe0 30 jours \xe0 la validation si laiss\xe9 vide', null=True, verbose_name="date d'\xe9ch\xe9ance de paiement", blank=True),
+        ),
+    ]

+ 19 - 0
coin/billing/migrations/0006_auto_20170608_2305.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', '0005_auto_20170608_2213'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='invoice',
+            name='date_due',
+            field=models.DateField(help_text='Le d\xe9lai de paiement sera fix\xe9 \xe0 20 jours \xe0 la validation si laiss\xe9 vide', null=True, verbose_name="date d'\xe9ch\xe9ance de paiement", blank=True),
+        ),
+    ]

+ 19 - 0
coin/billing/migrations/0007_auto_20170801_1530.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', '0006_auto_20170608_2305'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='invoice',
+            name='date_due',
+            field=models.DateField(help_text='Le d\xe9lai de paiement sera fix\xe9 \xe0 30 jours \xe0 la validation si laiss\xe9 vide', null=True, verbose_name="date d'\xe9ch\xe9ance de paiement", blank=True),
+        ),
+    ]

+ 19 - 0
coin/billing/migrations/0008_auto_20170802_2021.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', '0007_auto_20170801_1530'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='invoice',
+            name='status',
+            field=models.CharField(default='open', max_length=50, verbose_name='statut', choices=[('open', '\xc0 payer'), ('closed', 'R\xe9gl\xe9e'), ('trouble', 'Litige')]),
+        ),
+    ]

+ 54 - 0
coin/billing/migrations/0009_new_billing_system_schema.py

@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+from django.conf import settings
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('billing', '0008_auto_20170802_2021'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='PaymentAllocation',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('amount', models.DecimalField(null=True, verbose_name='montant', max_digits=5, decimal_places=2)),
+            ],
+        ),
+        migrations.AddField(
+            model_name='invoice',
+            name='date_last_reminder_email',
+            field=models.DateTimeField(null=True, verbose_name='Date du dernier email de relance envoy\xe9', blank=True),
+        ),
+        migrations.AddField(
+            model_name='payment',
+            name='label',
+            field=models.CharField(default='', max_length=500, null=True, verbose_name='libell\xe9', blank=True),
+        ),
+        migrations.AddField(
+            model_name='payment',
+            name='member',
+            field=models.ForeignKey(related_name='payments', on_delete=django.db.models.deletion.SET_NULL, default=None, blank=True, to=settings.AUTH_USER_MODEL, null=True, verbose_name='membre'),
+        ),
+        migrations.AlterField(
+            model_name='payment',
+            name='invoice',
+            field=models.ForeignKey(related_name='payments', verbose_name='facture associ\xe9e', blank=True, to='billing.Invoice', null=True),
+        ),
+        migrations.AddField(
+            model_name='paymentallocation',
+            name='invoice',
+            field=models.ForeignKey(related_name='allocations', verbose_name='facture associ\xe9e', to='billing.Invoice'),
+        ),
+        migrations.AddField(
+            model_name='paymentallocation',
+            name='payment',
+            field=models.ForeignKey(related_name='allocations', verbose_name='facture associ\xe9e', to='billing.Payment'),
+        ),
+    ]

+ 72 - 0
coin/billing/migrations/0010_new_billing_system_data.py

@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+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):
+
+    for invoice in Invoice.objects.all():
+
+        invoice_name = invoice.__unicode__()
+
+        related_payments = invoice.payments.all()
+
+        total_related_payments = sum([p.amount for p in related_payments])
+
+        if total_related_payments > invoice.amount:
+            error = "For invoice, current sum of payment is higher than total of invoice. Please fix this before running this migration" % invoice_name
+            raise AssertionError(error.encode('utf-8'))
+
+        if total_related_payments != 0 and not invoice.validated:
+            error = "Invoice %s is not validated but already has allocated payments. Please remove them before running this migration" % invoice_name
+            raise AssertionError(error.encode('utf-8'))
+
+
+def forwards(apps, schema_editor):
+
+    # Create allocation for all payment to their respective invoice
+    for payment in Payment.objects.all():
+        payment.member = payment.invoice.member
+        payment.allocate_to_invoice(payment.invoice)
+
+    # Update balance for all members
+    for member in Member.objects.all():
+
+        this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
+        this_member_payments = [p for p in member.payments.order_by("date")]
+
+        member.balance = compute_balance(this_member_invoices,
+                                         this_member_payments)
+        member.save()
+
+
+def compute_balance(invoices, payments):
+
+    active_payments = [p for p in payments if p.amount_not_allocated()    > 0]
+    active_invoices = [i for i in invoices if i.amount_remaining_to_pay() > 0]
+
+    s = 0
+    s -= sum([i.amount_remaining_to_pay() for i in active_invoices])
+    s += sum([p.amount_not_allocated()    for p in active_payments])
+
+    return s
+
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('billing', '0009_new_billing_system_schema'),
+        ('members', '0016_merge'),
+    ]
+
+    operations = [
+        migrations.RunPython(check_current_state),
+        migrations.RunPython(forwards),
+    ]

+ 458 - 41
coin/billing/models.py

@@ -2,27 +2,31 @@
 from __future__ import unicode_literals
 
 import datetime
-import random
+import logging
 import uuid
-import os
+import re
 from decimal import Decimal
+from dateutil.relativedelta import relativedelta
 
-from django.db import models
-from django.db.models.signals import post_save
+from django.conf import settings
+from django.db import models, transaction
+from django.utils import timezone
+from django.utils.encoding import python_2_unicode_compatible
 from django.dispatch import receiver
+from django.db.models.signals import post_save, post_delete
+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.html2pdf import render_as_pdf
-from coin.utils import private_files_storage, start_of_month, end_of_month
+from coin.utils import private_files_storage, start_of_month, end_of_month, \
+                       postgresql_regexp, send_templated_email,             \
+                       disable_for_loaddata
 from coin.isp_database.context_processors import branding
+from coin.isp_database.models import ISPInfo
 
-def next_invoice_number():
-    """Détermine un numéro de facture aléatoire"""
-    return '%s%02i-%i-%i' % (datetime.date.today().year,
-                             datetime.date.today().month,
-                             random.randrange(100, 999),
-                             random.randrange(100, 999))
+accounting_log = logging.getLogger("coin.billing")
 
 
 def invoice_pdf_filename(instance, filename):
@@ -33,11 +37,88 @@ def invoice_pdf_filename(instance, filename):
                                       uuid.uuid4())
 
 
+@python_2_unicode_compatible
+class InvoiceNumber:
+    """ Logic and validation of invoice numbers
+
+    Defines invoice numbers serie in a way that is legal in france.
+
+    https://www.service-public.fr/professionnels-entreprises/vosdroits/F23208#fiche-item-3
+
+    Our format is YYYY-MM-XXXXXX
+    - YYYY the year of the bill
+    - MM month of the bill
+    - XXXXXX a per-month sequence
+    """
+    RE_INVOICE_NUMBER = re.compile(
+        r'(?P<year>\d{4})-(?P<month>\d{2})-(?P<index>\d{6})')
+
+    def __init__(self, date, index):
+        self.date = date
+        self.index = index
+
+    def get_next(self):
+        return InvoiceNumber(self.date, self.index + 1)
+
+    def __str__(self):
+        return '{:%Y-%m}-{:0>6}'.format(self.date, self.index)
+
+    @classmethod
+    def parse(cls, string):
+        m = cls.RE_INVOICE_NUMBER.match(string)
+        if not m:
+            raise ValueError('Not a valid invoice number: "{}"'.format(string))
+
+        return cls(
+            datetime.date(
+                year=int(m.group('year')),
+                month=int(m.group('month')),
+                day=1),
+            int(m.group('index')))
+
+    @staticmethod
+    def time_sequence_filter(date, field_name='date'):
+        """ Build queryset filter to be used to get the invoices from the
+        numbering sequence of a given date.
+
+        :param field_name: the invoice field name to filter on.
+
+        :type date: datetime
+        :rtype: dict
+        """
+
+        return {'{}__month'.format(field_name): date.month}
+
+
+class InvoiceQuerySet(models.QuerySet):
+    def get_next_invoice_number(self, date):
+        last_invoice_number_str = self._get_last_invoice_number(date)
+
+        if last_invoice_number_str is None:
+            # It's the first bill of the month
+            invoice_number = InvoiceNumber(date, 1)
+        else:
+            invoice_number = InvoiceNumber.parse(last_invoice_number_str).get_next()
+
+        return str(invoice_number)
+
+    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']
+
+    def with_valid_number(self):
+        """ Excludes previous numbering schemes or draft invoices
+        """
+        return self.filter(number__regex=postgresql_regexp(
+            InvoiceNumber.RE_INVOICE_NUMBER))
+
+
 class Invoice(models.Model):
 
     INVOICES_STATUS_CHOICES = (
-        ('open', 'A payer'),
-        ('closed', 'Reglée'),
+        ('open', 'À payer'),
+        ('closed', 'Réglée'),
         ('trouble', 'Litige')
     )
 
@@ -45,18 +126,18 @@ class Invoice(models.Model):
                                     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,
                               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')
+        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(
-        default=end_of_month,
-        null=True,
-        verbose_name="date d'échéance de paiement")
+        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',
@@ -66,6 +147,17 @@ class Invoice(models.Model):
                            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é")
+
+    def save(self, *args, **kwargs):
+        # First save to get a PK
+        super(Invoice, self).save(*args, **kwargs)
+        # Then use that pk to build draft invoice number
+        if not self.validated and self.pk and not self.number:
+            self.number = 'DRAFT-{}'.format(self.pk)
+            self.save()
+
     def amount(self):
         """
         Calcul le montant de la facture
@@ -77,15 +169,18 @@ class Invoice(models.Model):
         return total.quantize(Decimal('0.01'))
     amount.short_description = 'Montant'
 
+    def amount_before_tax(self):
+        total = Decimal('0.0')
+        for detail in self.details.all():
+            total += detail.amount
+        return total.quantize(Decimal('0.01'))
+    amount_before_tax.short_description = 'Montant HT'
+
     def amount_paid(self):
         """
-        Calcul le montant payé de la facture en fonction des éléments
-        de paiements
+        Calcul le montant déjà payé à partir des allocations de paiements
         """
-        total = Decimal('0.0')
-        for payment in self.payments.all():
-            total += payment.amount
-        return total.quantize(Decimal('0.01'))
+        return sum([a.amount for a in self.allocations.all()])
     amount_paid.short_description = 'Montant payé'
 
     def amount_remaining_to_pay(self):
@@ -110,31 +205,105 @@ class Invoice(models.Model):
         pdf_file = render_as_pdf('billing/invoice_pdf.html', context)
         self.pdf.save('%s.pdf' % self.number, pdf_file)
 
+    @transaction.atomic
     def validate(self):
         """
         Switch invoice to validate mode. This set to False the draft field
         and generate the pdf
         """
-        if not self.is_pdf_exists():
-            self.validated = True
-            self.save()
-            self.generate_pdf()
+        self.date = datetime.date.today()
+        if not self.date_due:
+            self.date_due = self.date + datetime.timedelta(days=settings.PAYMENT_DELAY)
+        old_number = self.number
+        self.number = Invoice.objects.get_next_invoice_number(self.date)
+
+        self.validated = True
+        self.save()
+        self.generate_pdf()
+
+        accounting_log.info("Draft invoice %s validated as invoice %s. "
+                            "(Total amount : %f ; Member : %s)"
+                            % (old_number, self.number, self.amount(), self.member))
+        assert self.pdf_exists()
+        if self.member is not None:
+            update_accounting_for_member(self.member)
 
-    def is_pdf_exists(self):
+
+    def pdf_exists(self):
         return (self.validated
                 and bool(self.pdf)
                 and private_files_storage.exists(self.pdf.name))
 
     def get_absolute_url(self):
-        from django.core.urlresolvers import reverse
         return reverse('billing:invoice', args=[self.number])
 
     def __unicode__(self):
         return '#%s %0.2f€ %s' % (self.number, self.amount(), self.date_due)
 
+    def reminder_needed(self):
+
+        # If there's no member, there's nobody to be reminded
+        if self.member is None:
+            return False
+
+        # If bill is close or not validated yet, nope
+        if self.status != 'open' or not self.validated:
+            return False
+
+        # If bill is not at least one month old, nope
+        if self.date_due >= timezone.now()+relativedelta(weeks=-4):
+            return False
+
+        # If a reminder has been recently sent, nope
+        if (self.date_last_reminder_email
+            and (self.date_last_reminder_email
+                 >= timezone.now() + relativedelta(weeks=-3))):
+            return False
+
+        return True
+
+    def send_reminder(self, auto=False):
+        """ Envoie un courrier pour rappeler à un abonné qu'une facture est
+        en attente de paiement
+
+        :param bill: id of the bill to remind
+        :param auto: is it an auto email? (changes slightly template content)
+        """
+
+        if not self.reminder_needed():
+            return False
+
+        accounting_log.info("Sending reminder email to %s to pay invoice %s"
+                           % (str(self.member), str(self.number)))
+
+        isp_info = ISPInfo.objects.first()
+        kwargs = {}
+        # Il peut ne pas y avir d'ISPInfo, ou bien pas d'administrative_email
+        if isp_info and isp_info.administrative_email:
+            kwargs['from_email'] = isp_info.administrative_email
+
+        # Si le dernier courriel de relance a été envoyé il y a moins de trois
+        # semaines, n'envoi pas un nouveau courriel
+        send_templated_email(
+            to=self.member.email,
+            subject_template='billing/emails/reminder_for_unpaid_bill.txt',
+            body_template='billing/emails/reminder_for_unpaid_bill.html',
+            context={'member': self.member, 'branding': isp_info,
+                     'membership_info_url': settings.MEMBER_MEMBERSHIP_INFO_URL,
+                     'today': datetime.date.today,
+                     'auto_sent': auto},
+            **kwargs)
+
+        # Sauvegarde en base la date du dernier envoi de mail de relance
+        self.date_last_reminder_email = timezone.now()
+        self.save()
+        return True
+
     class Meta:
         verbose_name = 'facture'
 
+    objects = InvoiceQuerySet().as_manager()
+
 
 class InvoiceDetail(models.Model):
 
@@ -186,6 +355,11 @@ class Payment(models.Model):
         ('other', 'Autre')
     )
 
+    member = models.ForeignKey(Member, null=True, blank=True, default=None,
+                               related_name='payments',
+                               verbose_name='membre',
+                               on_delete=models.SET_NULL)
+
     payment_mean = models.CharField(max_length=100, null=True,
                                     default='transfer',
                                     choices=PAYMENT_MEAN_CHOICES,
@@ -193,23 +367,266 @@ class Payment(models.Model):
     amount = models.DecimalField(max_digits=5, decimal_places=2, null=True,
                                  verbose_name='montant')
     date = models.DateField(default=datetime.date.today)
-    invoice = models.ForeignKey(Invoice, verbose_name='facture',
-                                related_name='payments')
+    invoice = models.ForeignKey(Invoice, verbose_name='facture associée', null=True,
+                                blank=True, related_name='payments')
+
+    label = models.CharField(max_length=500,
+                             null=True, blank=True, default="",
+                             verbose_name='libellé')
+
+    def save(self, *args, **kwargs):
+
+        # Only if no amount already allocated...
+        if self.amount_already_allocated() == 0:
+
+            # If there's a linked invoice and no member defined
+            if self.invoice and not self.member:
+                # Automatically set member to invoice's member
+                self.member = self.invoice.member
+
+        super(Payment, self).save(*args, **kwargs)
+
+
+    def clean(self):
+
+        # Only if no amount already alloca ted...
+        if self.amount_already_allocated() == 0:
+
+            # 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():
+                raise ValidationError("This payment would pay more than the invoice's remaining to pay")
+
+    def amount_already_allocated(self):
+        return sum([ a.amount for a in self.allocations.all() ])
+
+    def amount_not_allocated(self):
+        return self.amount - self.amount_already_allocated()
+
+    @transaction.atomic
+    def allocate_to_invoice(self, invoice):
+
+        # 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_allocate = min(amount_can_pay, amount_to_pay)
+
+        accounting_log.info("Allocating %f from payment %s to invoice %s"
+                            % (float(amount_to_allocate), str(self.date),
+                               invoice.number))
+
+        PaymentAllocation.objects.create(invoice=invoice,
+                                         payment=self,
+                                         amount=amount_to_allocate)
+
+        # Close invoice if relevant
+        if (invoice.amount_remaining_to_pay() <= 0) and (invoice.status == "open"):
+            accounting_log.info("Invoice %s has been paid and is now closed"
+                                % invoice.number)
+            invoice.status = "closed"
+
+        invoice.save()
+        self.save()
 
     def __unicode__(self):
-        return 'Paiment de %0.2f€' % self.amount
+        if self.member is not None:
+            return 'Paiment de %0.2f€ le %s par %s' \
+                    % (self.amount, str(self.date), self.member)
+        else:
+            return 'Paiment de %0.2f€ le %s' \
+                    % (self.amount, str(self.date))
 
     class Meta:
         verbose_name = 'paiement'
 
 
-@receiver(post_save, sender=Payment)
-def set_invoice_as_paid_if_needed(sender, instance, **kwargs):
+# This corresponds to a (possibly partial) allocation of a given payment to
+# a given invoice.
+# E.g. consider an invoice I with total 15€ and a payment P with 10€.
+# 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',
+                                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,
+                                 verbose_name='montant')
+
+
+def get_active_payment_and_invoices(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_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]
+
+    return active_payments, active_invoices
+
+
+def update_accounting_for_member(member):
+    """
+    Met à jour le status des factures, des paiements et le solde du compte
+    d'un utilisateur
+    """
+
+    accounting_log.info("Updating accounting for member %s ..."
+                        % str(member))
+    accounting_log.info("Member %s current balance is %f ..."
+                        % (str(member), float(member.balance)))
+
+    reconcile_invoices_and_payments(member)
+
+    this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
+    this_member_payments = [p for p in member.payments.order_by("date")]
+
+    member.balance = compute_balance(this_member_invoices,
+                                     this_member_payments)
+    member.save()
+
+    accounting_log.info("Member %s new balance is %f"
+                        % (str(member),  float(member.balance)))
+
+
+def reconcile_invoices_and_payments(member):
     """
-    Lorsqu'un paiement est enregistré, vérifie si la facture est alors
-    complétement payée. Dans ce cas elle passe en réglée
+    Rapproche des factures et des paiements qui sont actifs (paiement non alloué
+    ou factures non entièrement payées) automatiquement.
     """
-    if (instance.invoice.amount_paid >= instance.invoice.amount and
-            instance.invoice.status == 'open'):
-        instance.invoice.status = 'closed'
-        instance.invoice.save()
+
+    active_payments, active_invoices = get_active_payment_and_invoices(member)
+
+    if active_payments == []:
+        accounting_log.info("(No active payment for %s. No invoice/payment "
+                            "reconciliation needed.)."
+                            % str(member))
+        return
+    elif active_invoices == []:
+        accounting_log.info("(No active invoice for %s. No invoice/payment "
+                            "reconciliation needed.)."
+                            % str(member))
+        return
+
+    accounting_log.info("Initiating reconciliation between "
+                        "invoice and payments for %s" % str(member))
+
+    while active_payments != [] and active_invoices != []:
+
+        # Only consider the oldest active payment and the oldest active invoice
+        p = active_payments[0]
+
+        # If this payment is to be allocated for a specific invoice...
+        if p.invoice:
+            # Assert that the invoice is still 'active'
+            assert p.invoice in active_invoices
+            i = p.invoice
+            accounting_log.info("Payment is to be allocated specifically to " \
+                                "invoice %s" % str(i.number))
+        else:
+            i = active_invoices[0]
+
+        # TODO : should add an assert that the ammount not allocated / remaining to
+        # pay is lower before and after calling the allocate_to_invoice
+
+        p.allocate_to_invoice(i)
+
+        active_payments, active_invoices = get_active_payment_and_invoices(member)
+
+    if active_payments == []:
+        accounting_log.info("No more active payment. Nothing to reconcile anymore.")
+    elif active_invoices == []:
+        accounting_log.info("No more active invoice. Nothing to reconcile anymore.")
+    return
+
+
+def compute_balance(invoices, payments):
+
+    active_payments = [p for p in payments if p.amount_not_allocated()    > 0]
+    active_invoices = [i for i in invoices if i.amount_remaining_to_pay() > 0]
+
+    s = 0
+    s -= sum([i.amount_remaining_to_pay() for i in active_invoices])
+    s += sum([p.amount_not_allocated()    for p in active_payments])
+
+    return s
+
+
+@receiver(post_save, sender=Payment)
+@disable_for_loaddata
+def payment_changed(sender, instance, created, **kwargs):
+
+    if created:
+        accounting_log.info("Adding payment %s (Date: %s, Member: %s, Amount: %s, Label: %s)."
+                            % (instance.pk, instance.date, instance.member,
+                                instance.amount, instance.label))
+    else:
+        accounting_log.info("Updating payment %s (Date: %s, Member: %s, Amount: %s, Label: %s, Allocated: %s)."
+                            % (instance.pk, instance.date, instance.member,
+                                instance.amount, instance.label,
+                                instance.amount_already_allocated()))
+
+    # If this payment is related to a member, update the accounting for
+    # this member
+    if (created or instance.amount_not_allocated() != 0) \
+    and (instance.member is not None):
+        update_accounting_for_member(instance.member)
+
+
+@receiver(post_save, sender=Invoice)
+@disable_for_loaddata
+def invoice_changed(sender, instance, created, **kwargs):
+
+    if created:
+        accounting_log.info("Creating draft invoice %s (Member: %s)."
+                            % ('DRAFT-{}'.format(instance.pk), instance.member))
+    else:
+        if not instance.validated:
+            accounting_log.info("Updating draft invoice %s (Member: %s)."
+                    % (instance.number, instance.member))
+        else:
+            accounting_log.info("Updating invoice %s (Member: %s, Total amount: %s, Amount paid: %s)."
+                    % (instance.number, instance.member, instance.amount(), instance.amount_paid() ))
+
+@receiver(post_delete, sender=PaymentAllocation)
+def paymentallocation_deleted(sender, instance, **kwargs):
+
+    invoice = instance.invoice
+
+    # Reopen invoice if relevant
+    if (invoice.amount_remaining_to_pay() > 0) and (invoice.status == "closed"):
+        accounting_log.info("Reopening invoice %s ..." % invoice.number)
+        invoice.status = "open"
+        invoice.save()
+
+
+@receiver(post_delete, sender=Payment)
+def payment_deleted(sender, instance, **kwargs):
+
+    accounting_log.info("Deleted payment %s (Date: %s, Member: %s, Amount: %s, Label: %s)."
+                        % (instance.pk, instance.date, instance.member,
+                            instance.amount, instance.label))
+
+    member = instance.member
+
+    if member is None:
+        return
+
+    this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
+    this_member_payments = [p for p in member.payments.order_by("date")]
+
+    member.balance = compute_balance(this_member_invoices,
+                                     this_member_payments)
+    member.save()
+
+

+ 1 - 1
coin/billing/templates/admin/billing/invoice/change_form.html

@@ -3,7 +3,7 @@
 {% block object-tools-items %}
     {% if not original.validated %}
         <li><a href="{% url 'admin:invoice_validate' id=object_id %}">Valider la facture</a></li>
-    {% elif original.is_pdf_exists %}
+    {% elif original.validated %}
         <li><a href="{% url 'billing:invoice_pdf' id=object_id %}">Télécharger le PDF</a></li>
     {% endif %}
     {{ block.super }}

+ 9 - 7
coin/billing/templates/billing/invoice.html

@@ -7,7 +7,7 @@
         <p>Émise le {{ invoice.date }}</p>
     </div>
     <div class="large-4 columns">
-        {% if invoice.is_pdf_exists %}<a href="{% url 'billing:invoice_pdf' id=invoice.number %}" target="_blank" class="button expand"><i class="fa fa-file-pdf-o"></i> Télécharger en PDF</a>{% endif %}
+        {% if invoice.validated %}<a href="{% url 'billing:invoice_pdf' id=invoice.number %}" target="_blank" class="button expand"><i class="fa fa-file-pdf-o"></i> Télécharger en PDF</a>{% endif %}
     </div>
 </div>
 
@@ -37,10 +37,14 @@
     </tbody>
 </table>
 
-<h4>Règlement</h4>
+<p>
+  Facture à payer avant le {{ invoice.date_due }}.
+</p>
+
+<h3>Règlement</h3>
 
 {% if invoice.payments.exists %}
-    <table id="invoice_payments" class="invoice-table full-width">
+    <table id="invoice-payments" class="invoice-table full-width">
         <thead>
             <tr>
                 <th>Type de paiement</th>
@@ -65,10 +69,8 @@
 {% endif %}
 
 {% if invoice.amount_remaining_to_pay > 0 %}
-    <div id="payment_howto" class="panel">
-        <small>
-            {% include "billing/payment_howto.html" %}
-        </small>
+    <div id="payment-howto" class="panel">
+        {% include "billing/payment_howto.html" %}
     </div>
 {% endif %}
 

+ 214 - 127
coin/billing/templates/billing/invoice_pdf.html

@@ -1,133 +1,220 @@
 {% load static isptags %}
 <html>
-	<head>
-		<title>Facture N°{{ invoice.number }}</title>
-
-		<style>
-		    @page {
-		        size: a4 portrait;
-		        @frame header_frame {
-		            -pdf-frame-content: header_content;
-		            left: 50pt; width: 512pt; top: 50pt; height: 70pt;
-		        }
-		        @frame content_frame {
-		            left: 50pt; width: 512pt; top: 120pt; height: 632pt;
-		        }
-		        @frame footer_frame {
-		            -pdf-frame-content: footer_content;
-		            left: 50pt; width: 512pt; top: 772pt; height: 30pt;
-		        }
-		    }
-		    body {
-		    	font-size: 9pt;
-		    }
-		    #coordonnees_isp {
-		    	font-size:9pt;
-		    }
-		    #coordonnees_client {
-		    	vertical-align: top;
-		    }
-
-		    table#details {
-		    	width:100%;
-		    }
-		    th.cell {border:0px;}
-		    .cell.result {border:0px; font-weight: bold}
-		    .cell { padding:2pt; border:1px solid #DDD; }
-		    .cell.label { width:400pt;}
-		    .cell.quantity {width:50pt;}
-		    .cell.amount {width:50pt;}
-		    .cell.total {width:50pt;}
-
-		    .period {color:#888;}
-
-		    #paiements {
-		    	background-color:#EEE;
-		    	padding:5pt;
-		    	font-size:80%;
-		    }
-		    #page_number {
-		    	float:right;
-		    }
-		</style>
-
-	</head>
+<head>
+  <title>Facture N°{{ invoice.number }}</title>
+
+  <style>
+  @page {
+    margin: 0; padding: 40pt;
+  }
+
+  html {
+    box-sizing: border-box;
+  }
+  *, *:before, *:after {
+    box-sizing: inherit;
+  }
+
+  body {
+    font-size: 9pt;
+    font-family: sans-serif;
+    color: #111;
+    padding: 0;
+  }
+  a {
+    color: #111;
+    text-decoration: none;
+  }
+
+  p {
+    margin: 0;
+  }
+  p + p {
+    margin-top: 10pt;
+  }
+  table {
+    border-collapse: collapse;
+    width: 100%;
+    margin: 40pt 0;
+  }
+
+  h1 {
+    font-size: 12pt;
+  }
+
+  header {
+    margin: 0 0 60pt 0;
+  }
+
+  header .logo {
+    height: 35pt;
+    margin: 0 auto 20pt;
+  }
+
+  footer {
+    position: fixed;
+    bottom: 0;
+    width: 100%;
+  }
+
+  footer .logo {
+    height: 20pt;
+  }
+
+  #coordonnees {}
+
+  #coordonnees td {
+    width: 50%;
+    vertical-align: top;
+  }
+
+  #details {}
+
+  #details th,
+  #details td {
+    padding: 5pt;
+    border:1px solid #ddd;
+  }
+  #details th.cell--empty,
+  #details td.cell--empty {border: 0;}
+
+  /* details cell layout */
+  .cell-label {width: 70%;}
+  .cell-quantity {width: 5%;}
+  .cell-amount {width: 10%;}
+  .cell-tax {width: 5%;}
+  .cell-total {width: 15%;}
+
+  /* details cell style */
+  .cell-result {
+    font-weight: bold;
+  }
+  .cell-quantity {
+    text-align: center;
+  }
+  .cell--money, 
+  .cell-tax {
+    text-align: right;
+    white-space: nowrap;
+  }
+
+  .cell-label p + p {
+    margin-top: 5pt;
+  }
+  .period {
+    color:#888;
+  }
+
+  #paiements {
+    margin-top: 40pt;
+    background-color: #f0f0f0;
+    padding: 10pt;
+    font-size: x-small;
+  }
+
+  footer {
+    font-size: xx-small;
+  }
+  .pagination {
+    float: right;
+  }
+  </style>
+</head>
 <body>
-	<div id="header_content">
-		<table widht="100%">
-			<tr>
-				<td><img id="logo" src="{{ branding.logoURL }}" height="70" /></td>
-				<td><h1>Facture N°{{ invoice.number }}</h1>
-					Le {{ invoice.date }}</td>
-			</tr>
-		</table>
-	</div>
-	<div id="footer_content">
-		<hr />
-		<table widht="100%">
-			<tr>
-				<td width="50"><img id="logo" src="{{ branding.logoURL }}" height="20" /></td>
-				<td>{{ branding.shortname|upper }}, association loi de 1901 à but non lucratif - SIRET : {{ branding.registeredoffice.siret }}</td>
-				<td width="20"><pdf:pagenumber>
-					/<pdf:pagecount>
-				</td>
-			</tr>
-		</table>
-	</div>
-	<table>
-		<tr>
-			<td id="coordonnees_isp">
-				<p>
-                {% multiline_isp_addr branding %}
-				<p>{{ branding.email }}<br/>
-				<a href="{{ branding.website }}">{{ branding.website }}</a></p>
-			</td>
-			<td id="coordonnees_client">
-				<strong>Facturé à :</strong><br/>
-                {% with member=invoice.member %}
-                    {{ member.last_name }} {{ member.first_name }}<br />
-                    {% if member.organization_name != "" %}{{ member.organization_name }}<br />{% endif %}
-                    {% if member.address %}{{member.address}}<br />{% endif %}
-                    {% if member.postal_code and member.city %}
-                        {{ member.postal_code }} {{ member.city }}
-                    {% endif %}
-                {% endwith %}
-			</td>
-		</tr>
-	</table>
-
-	<hr />
-	Facture N°{{ invoice.number }}
-
-	<table id="details" repeat="1">
-		<thead>
-			<tr>
-				<th class="cell label"></th>
-				<th class="cell quantity">Quantité</th>
-				<th class="cell amount">PU</th>
-				<th class="cell total">Total</th>
-			</tr>
-		</thead>
-		<tbody>
-			{% for detail in invoice.details.all %}
-			<tr>
-				<td class="cell label">{{ detail.label }}
-					{% if detail.period_from and detail.period_to %}<br/><span class="period">Pour la période du {{ detail.period_from }} au {{ detail.period_to }}{% endif %}</span></td>
-				<td class="cell quantity">{{ detail.quantity }}</td>
-				<td class="cell amount">{{ detail.amount }}€</td>
-				<td class="cell total">{{ detail.total }}€</td>
-			</tr>
-			{% endfor %}
-			<tr>
-				<td class="cell result"></td>
-				<td class="cell result total_ttc" colspan="2">Total TTC</td>
-				<td class="cell result invoice_amount">{{ invoice.amount }}€</td>
-			</tr>
-		</tbody>
-	</table>
-	<div id="paiements">
-        {% include "billing/payment_howto.html" %}
-	</div>
 
+  <header>
+    <img class="logo" src="{{ branding.logoURL }}" />
+    <h1>Facture N°{{ invoice.number }}</h1>
+    <p>Le {{ invoice.date }}</p>
+  </header>
+
+  <table id="coordonnees">
+    <tr>
+      <td id="coordonnees_isp">
+        <p>
+        {% multiline_isp_addr branding %}
+        </p>
+        <p>
+        <a href="mailto:{{ branding.email }}">{{ branding.email }}</a><br/>
+        <a href="{{ branding.website }}">{{ branding.website }}</a><br />
+        {{ branding.phone_number }}
+        </p>
+      </td>
+      <td id="coordonnees_client">
+        <p>
+        <strong>Facturé à :</strong><br/>
+        {% with member=invoice.member %}
+        {{ member.last_name }} {{ member.first_name }}<br />
+        {% if member.organization_name != "" %}{{ member.organization_name }}<br />{% endif %}
+        {% if member.address %}{{member.address}}<br />{% endif %}
+        {% if member.postal_code and member.city %}
+        {{ member.postal_code }} {{ member.city }}
+        {% endif %}
+        {% endwith %}
+        </p>
+      </td>
+    </tr>
+  </table>
+
+  <table id="details" repeat="1">
+    <thead>
+      <tr>
+        <th class="cell-label cell--empty"></th>
+        <th class="cell-quantity">Quantité</th>
+        <th class="cell-amount cell--money">PU (HT)</th>
+        <th class="cell-label cell-tax">TVA</th>
+        <th class="cell-total cell--money">Total</th>
+      </tr>
+    </thead>
+    <tbody>
+      {% for detail in invoice.details.all %}
+      <tr>
+        <td class="cell-label">
+          <p>
+          {{ detail.label }}
+          {% if detail.offersubscription %}
+            <br/>
+            <span class="subscription">{{ detail.offersubscription.offer.name }}
+            {% if detail.offersubscription.offer.reference %} ({{ detail.offersubscription.get_subscription_reference }}){% endif %}
+            </span>
+          {% endif %}
+          </p>
+          {% if detail.period_from and detail.period_to %}
+          <p class="period">Pour la période du {{ detail.period_from }} au {{ detail.period_to }}</p>
+          {% endif %}
+        </td>
+        <td class="cell-quantity">{{ detail.quantity }}</td>
+        <td class="cell-amount cell--money">{{ detail.amount }}€</td>
+        <td class="cell-tax">{{ detail.tax }}%</td>
+        <td class="cell-total cell--money">{{ detail.total }}€</td>
+      </tr>
+      {% endfor %}
+      
+      <tr>
+        <td class="cell-result cell--empty"></td>
+        <td class="result-label " colspan="3">Total HT</td>
+        <td class="cell--money ">{{ invoice.amount_before_tax }}€</td>
+      </tr>
+      <tr>
+        <td class="cell-result cell--empty"></td>
+        <td class="cell-result result-label" colspan="3">Total TTC</td>
+        <td class="cell-result result-total cell--money">{{ invoice.amount }}€</td>
+      </tr>
+       
+    </tbody>
+  </table>
+  <p>
+    Facture à payer avant le {{ invoice.date_due }}.
+  </p>
+
+  <div id="paiements">
+  {% include "billing/payment_howto.html" %}
+  </div>
+
+  <footer>
+    <img class="logo" src="{{ branding.logoURL }}" />
+    <p class="pagination"><pdf:pagenumber>/<pdf:pagecount></p>
+    <p>{{ branding.shortname|upper }}, association loi de 1901 à but non lucratif - SIRET : {{ branding.registeredoffice.siret }}</p>
+  </footer>
 </body>
 </html>

+ 26 - 14
coin/billing/templates/billing/payment_howto.html

@@ -1,19 +1,31 @@
 {% load isptags %}
 
-<p><strong>Merci de préférer si possible le paiement par virement</strong></p>
-
 <p>
-<strong>Virement</strong><br />
-Titulaire du compte : {{ branding.shortname|upper }}<br/>
-IBAN : {{ branding.bankinfo.iban|pretty_iban }}<br />
+    <strong>Merci de préférer si possible le paiement par virement</strong>
+</p>
+<p>
+    <strong>Virement</strong><br />
+    Titulaire du compte : {% firstof branding.shortname branding.name %}<br/>
+    IBAN : {{ branding.bankinfo.iban|pretty_iban }}<br />
 
-{% if branding.bankinfo.bic %}
-    BIC : {{ branding.bankinfo.bic }}<br />
-{% endif %}
-Merci de faire figurer le code suivant sur votre virement : <strong>#{{ invoice.member.id }}</strong>
-<br /><br />
-<strong>Chèque</strong><br />
-{% with address=branding.registeredoffice %}
-Paiement par chèque à l'ordre de "{{ branding.bankinfo.check_order }}" envoyé à l'adresse : {{ branding.name|upper }}, {{ address.extended_address }}, {{ address.street_address }}, {{ address.postal_code }} {{ address.locality }}
-{% endwith %}
+    {% if branding.bankinfo.bic %}
+        BIC : {{ branding.bankinfo.bic }}<br />
+    {% endif %}
+    {% if invoice %}
+    Merci de faire figurer la référence suivante sur votre virement : <strong>{{ invoice.number }}</strong>
+    {% endif %}
+</p>
+<p>
+    <strong>Chèque</strong><br />
+    {% with address=branding.registeredoffice %}
+    Paiement par chèque à l'ordre de "{{ branding.bankinfo.check_order }}" envoyé à l'adresse : <br />
+    {% firstof branding.shortname branding.name %}<br />
+    {% if address.extended_address %}
+    {{ address.extended_address }}<br />
+    {% endif %}
+    {% if address.street_address %}
+    {{ address.street_address }}<br />
+    {% endif %}
+    {{ address.postal_code }} {{ address.locality }}
+    {% endwith %}
 </p>

+ 182 - 27
coin/billing/tests.py

@@ -3,10 +3,13 @@ from __future__ import unicode_literals
 
 import datetime
 from decimal import Decimal
+
+from django.conf import settings
 from django.test import TestCase, Client
+from freezegun import freeze_time
 from coin.members.tests import MemberTestsUtils
 from coin.members.models import Member, LdapUser
-from coin.billing.models import Invoice
+from coin.billing.models import Invoice, InvoiceQuerySet, InvoiceDetail, Payment
 from coin.offers.models import Offer, OfferSubscription
 from coin.billing.create_subscriptions_invoices import create_member_invoice_for_a_period
 from coin.billing.create_subscriptions_invoices import create_all_members_invoices_for_a_period
@@ -33,7 +36,8 @@ class BillingInvoiceCreationTests(TestCase):
 
     def tearDown(self):
         # Supprime l'utilisateur LDAP créé
-        LdapUser.objects.get(pk=self.username).delete()
+        if settings.LDAP_ACTIVATE:
+            LdapUser.objects.get(pk=self.username).delete()
 
     def test_first_subscription_invoice_has_initial_fees(self):
         """
@@ -111,6 +115,78 @@ class BillingInvoiceCreationTests(TestCase):
         self.assertEqual(invoice_test_2.details.first().period_to,
                          datetime.date(2014, 5, 31))
 
+    def test_invoice_amount(self):
+        invoice = Invoice(member=self.member)
+        invoice.save()
+
+        invoice.details.create(label=self.offer.name,
+                               amount=100,
+                               offersubscription=self.subscription,
+                               period_from=datetime.date(2014, 1, 1),
+                               period_to=datetime.date(2014, 3, 31),
+                               tax=0)
+
+        invoice.details.create(label=self.offer.name,
+                               amount=10,
+                               offersubscription=self.subscription,
+                               period_from=datetime.date(2014, 6, 1),
+                               period_to=datetime.date(2014, 8, 31),
+                               tax=10)
+
+        self.assertEqual(invoice.amount(), 111)
+
+    def test_invoice_partial_payment(self):
+        invoice = Invoice(member=self.member)
+        invoice.save()
+
+        invoice.details.create(label=self.offer.name,
+                               amount=100,
+                               offersubscription=self.subscription,
+                               period_from=datetime.date(2014, 1, 1),
+                               period_to=datetime.date(2014, 3, 31),
+                               tax=0)
+        invoice.validate()
+        invoice.save()
+
+        self.assertEqual(invoice.status, 'open')
+        p1 = Payment.objects.create(member=self.member,
+                                    invoice=invoice,
+                                    payment_mean='cash',
+                                    amount=10)
+        p1.save()
+
+        invoice = Invoice.objects.get(pk=invoice.pk)
+        self.assertEqual(invoice.status, 'open')
+
+        p2 = Payment.objects.create(member=self.member,
+                                    invoice=invoice,
+                                    payment_mean='cash',
+                                    amount=90)
+        p2.save()
+
+        invoice = Invoice.objects.get(pk=invoice.pk)
+        self.assertEqual(invoice.status, 'closed')
+
+    def test_invoice_amount_before_tax(self):
+        invoice = Invoice(member=self.member)
+        invoice.save()
+
+        invoice.details.create(label=self.offer.name,
+                               amount=100,
+                               offersubscription=self.subscription,
+                               period_from=datetime.date(2014, 1, 1),
+                               period_to=datetime.date(2014, 3, 31),
+                               tax=0)
+
+        invoice.details.create(label=self.offer.name,
+                               amount=10,
+                               offersubscription=self.subscription,
+                               period_from=datetime.date(2014, 6, 1),
+                               period_to=datetime.date(2014, 8, 31),
+                               tax=10)
+
+        self.assertEqual(invoice.amount_before_tax(), 110)
+
     def test_non_billable_offer_isnt_charged(self):
         """
         Test qu'une offre non facturable n'est pas prise en compte
@@ -139,31 +215,31 @@ class BillingInvoiceCreationTests(TestCase):
 
 class BillingTests(TestCase):
 
-    # def test_download_invoice_pdf_return_a_pdf(self):
-    #     """
-    #     Test que le téléchargement d'une facture en format pdf retourne bien un
-    #     pdf
-    #     """
-    #     # Créé un membre
-    #     username = MemberTestsUtils.get_random_username()
-    #     member = Member(first_name='A', last_name='A',
-    #                     username=username)
-    #     member.set_password('1234')
-    #     member.save()
-
-    #     # Créé une facture
-    #     invoice = Invoice(member=member)
-    #     invoice.save()
-    #     invoice.validate()
-
-    #     # Se connect en tant que le membre
-    #     client = Client()
-    #     client.login(username=username, password='1234')
-    #     # Tente de télécharger la facture
-    #     response = client.get('/billing/invoice/%i/pdf' % invoice.id)
-    #     # Vérifie return code 200 et contient chaine %PDF-1.
-    #     self.assertContains(response, '%PDF-1.', status_code=200, html=False)
-    #     member.delete()
+    def test_download_invoice_pdf_return_a_pdf(self):
+        """
+        Test que le téléchargement d'une facture en format pdf retourne bien un
+        pdf
+        """
+        # Créé un membre
+        username = MemberTestsUtils.get_random_username()
+        member = Member(first_name='A', last_name='A',
+                        username=username)
+        member.set_password('1234')
+        member.save()
+
+        # Créé une facture
+        invoice = Invoice(member=member)
+        invoice.save()
+        invoice.validate()
+
+        # Se connect en tant que le membre
+        client = Client()
+        client.login(username=username, password='1234')
+        # Tente de télécharger la facture
+        response = client.get('/billing/invoice/%i/pdf' % invoice.id)
+        # Vérifie return code 200 et contient chaine %PDF-1.
+        self.assertContains(response, b'%PDF-1.', status_code=200, html=False)
+        member.delete()
 
     def test_that_only_owner_of_invoice_can_access_it(self):
         """
@@ -219,3 +295,82 @@ class BillingTests(TestCase):
 
         member_a.delete()
         member_b.delete()
+
+
+class InvoiceQuerySetTests(TestCase):
+    def test_get_first_invoice_number_ever(self):
+        self.assertEqual(
+            Invoice.objects.get_next_invoice_number(datetime.date(2016,1,1)),
+            '2016-01-000001')
+
+    @freeze_time('2016-01-01')
+    def test_get_first_of_month_invoice_number(self):
+        # One bill on a month…
+        Invoice.objects.create().validate()
+
+        # … Does not affect the numbering of following month.
+        self.assertEqual(
+            Invoice.objects.get_next_invoice_number(datetime.date(2016,2,15)),
+            '2016-02-000001')
+
+    @freeze_time('2016-01-01')
+    def test_number_workflow(self):
+        iv = Invoice.objects.create()
+        self.assertEqual(iv.number, 'DRAFT-1')
+        iv.validate()
+        self.assertRegexpMatches(iv.number, r'2016-01-000001$')
+
+    @freeze_time('2016-01-01')
+    def test_get_second_of_month_invoice_number(self):
+        first_bill = Invoice.objects.create(date=datetime.date(2016,1,1))
+        first_bill.validate()
+        self.assertEqual(
+            Invoice.objects.get_next_invoice_number(datetime.date(2016,1,1)),
+            '2016-01-000002')
+
+    def test_bill_date_is_validation_date(self):
+        bill = Invoice.objects.create(date=datetime.date(2016,1,1))
+        self.assertEqual(bill.date, datetime.date(2016,1,1))
+
+        with freeze_time('2017-01-01'):
+            bill.validate()
+            self.assertEqual(bill.date, datetime.date(2017, 1, 1))
+            self.assertEqual(bill.number, '2017-01-000001')
+
+
+class PaymentInvoiceAutoReconciliationTests(TestCase):
+
+    def test_accounting_update(self):
+
+        johndoe =  Member.objects.create(username=MemberTestsUtils.get_random_username(),
+                                         first_name="John",
+                                         last_name="Doe",
+                                         email="johndoe@yolo.test")
+        johndoe.set_password("trololo")
+
+        # First facture
+        invoice = Invoice.objects.create(number="1337",
+                                         member=johndoe)
+        InvoiceDetail.objects.create(label="superservice",
+                                     amount="15.0",
+                                     invoice=invoice)
+        invoice.validate()
+
+        # Second facture
+        invoice2 = Invoice.objects.create(number="42",
+                                         member=johndoe)
+        InvoiceDetail.objects.create(label="superservice",
+                                     amount="42",
+                                     invoice=invoice2)
+        invoice2.validate()
+
+        # Payment
+        payment = Payment.objects.create(amount=20,
+                                         member=johndoe)
+
+        invoice.delete()
+        invoice2.delete()
+        payment.delete()
+        johndoe.delete()
+
+

+ 19 - 0
coin/configuration/migrations/0004_auto_20161015_1837.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('configuration', '0003_configuration_comment'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='configuration',
+            name='polymorphic_ctype',
+            field=models.ForeignKey(related_name='polymorphic_configuration.configuration_set+', editable=False, to='contenttypes.ContentType', null=True),
+        ),
+    ]

+ 7 - 0
coin/context_processors.py

@@ -0,0 +1,7 @@
+from django.conf import settings
+
+
+def installed_apps(request):
+    """ Expose the settings INSTALLED_APPS to templates
+    """
+    return {'INSTALLED_APPS': settings.INSTALLED_APPS}

+ 3 - 3
coin/html2pdf.py

@@ -3,12 +3,12 @@ from __future__ import unicode_literals
 
 import os
 import re
-from xhtml2pdf import pisa
 from tempfile import NamedTemporaryFile
 
 from django.conf import settings
 from django.template import loader, Context
 from django.core.files import File
+from weasyprint import HTML
 
 
 def link_callback(uri, rel):
@@ -51,12 +51,12 @@ def render_as_pdf(template, context):
     converti en PDF via le module xhtml2pdf.
     Renvoi un objet de type File
     """
-    
+
     template = loader.get_template(template)
     html = template.render(Context(context))
     file = NamedTemporaryFile()
 
-    pisaStatus = pisa.CreatePDF(html, dest=file, link_callback=link_callback)
+    pisaStatus = HTML(string=html).write_pdf(file)
     file.flush()
 
     return File(open(file.name))

+ 15 - 1
coin/isp_database/admin.py

@@ -2,9 +2,21 @@
 from __future__ import unicode_literals
 
 from django.contrib import admin
+from django.forms import ModelForm
+
+from localflavor.fr.forms import FRPhoneNumberField
 
 from coin.isp_database.models import ISPInfo, RegisteredOffice, OtherWebsite, ChatRoom, CoveredArea, BankInfo
 
+class ISPAdminForm(ModelForm):
+
+    class Meta:
+        model = ISPInfo
+        exclude = []
+
+    phone_number = FRPhoneNumberField(required=False,
+                                      help_text='Main contact phone number')
+
 
 class SingleInstanceAdminMixin(object):
     """Hides the "Add" button when there is already an instance"""
@@ -70,7 +82,7 @@ class ISPInfoAdmin(SingleInstanceAdminMixin, admin.ModelAdmin):
             ('latitude', 'longitude'))}),
         ('Contact', {'fields': (
             ('email', 'mainMailingList'),
-            'website')}),
+            'website', 'phone_number')}),
         ('Extras', {
             'fields': ('administrative_email', 'support_email', 'lists_url'),
             'description':
@@ -82,5 +94,7 @@ class ISPInfoAdmin(SingleInstanceAdminMixin, admin.ModelAdmin):
                CoveredAreaInline)
     save_on_top = True
 
+    # Use custom form
+    form = ISPAdminForm
 
 admin.site.register(ISPInfo, ISPInfoAdmin)

+ 20 - 0
coin/isp_database/migrations/0010_ispinfo_phone_number.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('isp_database', '0009_auto_20151230_1759'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='ispinfo',
+            name='phone_number',
+            field=models.CharField(help_text='Main contact phone number', max_length=25, verbose_name='phone number', blank=True),
+            preserve_default=True,
+        ),
+    ]

+ 21 - 0
coin/isp_database/migrations/0011_auto_20170227_0029.py

@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import re
+import django.core.validators
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('isp_database', '0010_ispinfo_phone_number'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='chatroom',
+            name='url',
+            field=models.CharField(max_length=256, verbose_name='URL', validators=[django.core.validators.RegexValidator(regex=re.compile('(?P<protocol>\\w+://)(?P<server>[\\w\\.]+)/(?P<channel>.*)'), message='Enter a value of the form  <proto>://<server>/<channel>')]),
+        ),
+    ]

+ 21 - 0
coin/isp_database/migrations/0011_auto_20170309_1247.py

@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import multiselectfield.db.fields
+from coin.isp_database.models import TECHNOLOGIES
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('isp_database', '0010_ispinfo_phone_number'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='coveredarea',
+            name='technologies',
+            field=multiselectfield.db.fields.MultiSelectField(max_length=42, choices=TECHNOLOGIES),
+        ),
+    ]

+ 21 - 0
coin/isp_database/migrations/0012_auto_20170328_2257.py

@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import re
+import django.core.validators
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('isp_database', '0011_auto_20170309_1247'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='chatroom',
+            name='url',
+            field=models.CharField(max_length=256, verbose_name='URL', validators=[django.core.validators.RegexValidator(regex=re.compile('(?P<protocol>\\w+://)(?P<server>[\\w\\.]+)/(?P<channel>.*)'), message='Enter a value of the form  <proto>://<server>/<channel>')]),
+        ),
+    ]

+ 15 - 0
coin/isp_database/migrations/0013_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 = [
+        ('isp_database', '0012_auto_20170328_2257'),
+        ('isp_database', '0011_auto_20170227_0029'),
+    ]
+
+    operations = [
+    ]

+ 151 - 0
coin/isp_database/migrations/0014_auto_20170802_2021.py

@@ -0,0 +1,151 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import multiselectfield.db.fields
+import django.core.validators
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('isp_database', '0013_merge'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='chatroom',
+            options={'verbose_name': 'Salon de discussions', 'verbose_name_plural': 'Salons de discussions'},
+        ),
+        migrations.AlterModelOptions(
+            name='coveredarea',
+            options={'verbose_name': 'Zone couverte', 'verbose_name_plural': 'Zones couvertes'},
+        ),
+        migrations.AlterModelOptions(
+            name='ispinfo',
+            options={'verbose_name': 'Information du FAI', 'verbose_name_plural': 'Informations du FAI'},
+        ),
+        migrations.AlterModelOptions(
+            name='otherwebsite',
+            options={'verbose_name': 'Autre site Internet', 'verbose_name_plural': 'Autres sites Internet'},
+        ),
+        migrations.AlterModelOptions(
+            name='registeredoffice',
+            options={'verbose_name': 'Si\xe8ge social', 'verbose_name_plural': 'Si\xe8ges sociaux'},
+        ),
+        migrations.AlterField(
+            model_name='coveredarea',
+            name='name',
+            field=models.CharField(max_length=512, verbose_name='Nom'),
+        ),
+        migrations.AlterField(
+            model_name='coveredarea',
+            name='technologies',
+            field=multiselectfield.db.fields.MultiSelectField(max_length=42, verbose_name='Technologie', choices=[('ftth', 'FTTH'), ('dsl', '*DSL'), ('wifi', 'WiFi'), ('vpn', 'VPN'), ('cube', 'Brique Internet')]),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='creationDate',
+            field=models.DateField(help_text='Date de cr\xe9ation de la structure l\xe9gale', null=True, verbose_name='Date de cr\xe9ation', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='description',
+            field=models.TextField(help_text='Description courte du projet', verbose_name='Description', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='email',
+            field=models.EmailField(help_text='Adresse courriel de contact', max_length=254, verbose_name='Courriel'),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='ffdnMemberSince',
+            field=models.DateField(help_text='Date \xe0 laquelle le FAI a rejoint la F\xe9d\xe9ration FDN', null=True, verbose_name='Membre de FFDN depuis', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='latitude',
+            field=models.FloatField(help_text='Coordonn\xe9es latitudinales du si\xe8ge', null=True, verbose_name='Latitude', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='logoURL',
+            field=models.URLField(help_text='Adresse HTTP(S) du logo du FAI', verbose_name='URL du logo', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='longitude',
+            field=models.FloatField(help_text='Coordonn\xe9es longitudinales du si\xe8ge', null=True, verbose_name='Longitude', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='mainMailingList',
+            field=models.EmailField(help_text='Principale liste de discussion publique', max_length=254, verbose_name='Liste de discussion principale', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='name',
+            field=models.CharField(help_text='Nom du FAI', max_length=512, verbose_name='Nom'),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='phone_number',
+            field=models.CharField(help_text='Num\xe9ro de t\xe9l\xe9phone de contact principal', max_length=25, verbose_name='Num\xe9ro de t\xe9l\xe9phone', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='progressStatus',
+            field=models.PositiveSmallIntegerField(blank=True, help_text="\xc9tat d'avancement du FAI", null=True, verbose_name="\xc9tat d'avancement", validators=[django.core.validators.MaxValueValidator(7)]),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='shortname',
+            field=models.CharField(help_text='Nom plus court', max_length=15, verbose_name='Abr\xe9viation', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='website',
+            field=models.URLField(help_text='Adresse URL du site Internet', verbose_name='URL du site Internet', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='otherwebsite',
+            name='name',
+            field=models.CharField(max_length=512, verbose_name='Nom'),
+        ),
+        migrations.AlterField(
+            model_name='registeredoffice',
+            name='country_name',
+            field=models.CharField(max_length=512, verbose_name='Pays'),
+        ),
+        migrations.AlterField(
+            model_name='registeredoffice',
+            name='extended_address',
+            field=models.CharField(max_length=512, verbose_name='Adresse compl\xe9mentaire', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='registeredoffice',
+            name='locality',
+            field=models.CharField(max_length=512, verbose_name='Ville'),
+        ),
+        migrations.AlterField(
+            model_name='registeredoffice',
+            name='post_office_box',
+            field=models.CharField(max_length=512, verbose_name='Bo\xeete postale', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='registeredoffice',
+            name='postal_code',
+            field=models.CharField(max_length=512, verbose_name='Code postal', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='registeredoffice',
+            name='region',
+            field=models.CharField(max_length=512, verbose_name='R\xe9gion'),
+        ),
+        migrations.AlterField(
+            model_name='registeredoffice',
+            name='street_address',
+            field=models.CharField(max_length=512, verbose_name='Adresse', blank=True),
+        ),
+    ]

+ 77 - 39
coin/isp_database/models.py

@@ -8,9 +8,12 @@ from django.core.exceptions import ValidationError
 from localflavor.generic.models import IBANField, BICField
 from localflavor.fr.models import FRSIRETField
 
+from multiselectfield import MultiSelectField
+
 from coin.members.models import count_active_members
 from coin.offers.models import count_active_subscriptions
 from coin import utils
+from coin.validation import chatroom_url_validator
 
 # API version, see http://db.ffdn.org/format
 API_VERSION = 0.1
@@ -50,70 +53,88 @@ class ISPInfo(SingleInstanceMixin, models.Model):
         return count_active_subscriptions()
 
     name = models.CharField(max_length=512,
-                            help_text="The ISP's name")
+                            verbose_name="Nom",
+                            help_text="Nom du FAI")
     # Length required by the spec
     shortname = models.CharField(max_length=15, blank=True,
-                                 help_text="Shorter name")
+                                 verbose_name="Abréviation",
+                                 help_text="Nom plus court")
     description = models.TextField(blank=True,
-                                   help_text="Short text describing the project")
+                                   verbose_name="Description",
+                                   help_text="Description courte du projet")
     logoURL = models.URLField(blank=True,
-                              verbose_name="logo URL",
-                              help_text="HTTP(S) URL of the ISP's logo")
+                              verbose_name="URL du logo",
+                              help_text="Adresse HTTP(S) du logo du FAI")
     website = models.URLField(blank=True,
-                              help_text='URL to the official website')
-    email = models.EmailField(max_length=254,
-                              help_text="Contact email address")
-    mainMailingList = models.EmailField(max_length=254, blank=True,
-                                        verbose_name="main mailing list",
-                                        help_text="Main public mailing-list")
+                              verbose_name="URL du site Internet",
+                              help_text='Adresse URL du site Internet')
+    email = models.EmailField(verbose_name="Courriel",
+                              help_text="Adresse courriel de contact")
+    mainMailingList = models.EmailField(blank=True,
+                                        verbose_name="Liste de discussion principale",
+                                        help_text="Principale liste de discussion publique")
+    phone_number = models.CharField(max_length=25, blank=True,
+                                    verbose_name="Numéro de téléphone",
+                                    help_text='Numéro de téléphone de contact principal')
     creationDate = models.DateField(blank=True, null=True,
-                                    verbose_name="creation date",
-                                     help_text="Date of creation for legal structure")
+                                    verbose_name="Date de création",
+                                    help_text="Date de création de la structure légale")
     ffdnMemberSince = models.DateField(blank=True, null=True,
-                                       verbose_name="FFDN member since",
-                                       help_text="Date at wich the ISP joined the Federation")
+                                       verbose_name="Membre de FFDN depuis",
+                                       help_text="Date à laquelle le FAI a rejoint la Fédération FDN")
     # TODO: choice field
     progressStatus = models.PositiveSmallIntegerField(
         validators=[MaxValueValidator(7)],
-        blank=True, null=True, verbose_name='progress status',
-        help_text="Progression status of the ISP")
+        blank=True, null=True, verbose_name="État d'avancement",
+        help_text="État d'avancement du FAI")
     # TODO: better model for coordinates
     latitude = models.FloatField(blank=True, null=True,
-        help_text="Coordinates of the registered office (latitude)")
+                                 verbose_name="Latitude",
+                                 help_text="Coordonnées latitudinales du siège")
     longitude = models.FloatField(blank=True, null=True,
-        help_text="Coordinates of the registered office (longitude)")
+                                  verbose_name="Longitude",
+                                  help_text="Coordonnées longitudinales du siège")
 
     # 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",
+    #memberCount = models.PositiveIntegerField(help_text="Nombre de membres",
     #                                          default=0)
     #subscriberCount = models.PositiveIntegerField(
-    #    help_text="Number of subscribers to an internet access",
+    #    help_text="Nombre d'abonnés à un accès Internet",
     #    default=0)
 
     # field outside of db-ffdn format:
     administrative_email = models.EmailField(
-        max_length=254, blank=True, verbose_name="contact administratif",
+        blank=True, verbose_name="contact administratif",
         help_text='Adresse email pour les contacts administratifs (ex: bureau)')
 
     support_email = models.EmailField(
-        max_length=254, blank=True, verbose_name="contact de support",
+        blank=True, verbose_name="contact de support",
         help_text="Adresse email pour les demandes de support technique")
 
     lists_url = models.URLField(
         verbose_name="serveur de listes", blank=True,
         help_text="URL du serveur de listes de discussions/diffusion")
 
+    class Meta:
+        verbose_name = "Information du FAI"
+        verbose_name_plural = "Informations du FAI"
+
     @property
     def version(self):
-        """Version of the API"""
+        """Version de l'API"""
         return API_VERSION
 
     @property
     def main_chat_verbose(self):
-        m = utils.re_chat_url.match(self.chatroom_set.first().url)
-        return '{channel} sur {server}'.format(**(m.groupdict()))
+        first_chatroom = self.chatroom_set.first()
+        if first_chatroom:
+            m = utils.re_chat_url.match(first_chatroom.url)
+            if m:
+                return '{channel} sur {server}'.format(**(m.groupdict()))
+
+        return None
 
     def get_absolute_url(self):
         return '/isp.json'
@@ -158,25 +179,33 @@ class ISPInfo(SingleInstanceMixin, models.Model):
 
 
 class OtherWebsite(models.Model):
-    name = models.CharField(max_length=512)
+    name = models.CharField(max_length=512, verbose_name="Nom")
     url = models.URLField(verbose_name="URL")
     isp = models.ForeignKey(ISPInfo)
 
+    class Meta:
+        verbose_name = "Autre site Internet"
+        verbose_name_plural = "Autres sites Internet"
+
 
 class RegisteredOffice(models.Model):
     """ http://json-schema.org/address """
-    post_office_box = models.CharField(max_length=512, blank=True)
-    extended_address = models.CharField(max_length=512, blank=True)
-    street_address = models.CharField(max_length=512, blank=True)
-    locality = models.CharField(max_length=512)
-    region = models.CharField(max_length=512)
-    postal_code = models.CharField(max_length=512, blank=True)
-    country_name = models.CharField(max_length=512)
+    post_office_box = models.CharField(max_length=512, blank=True, verbose_name="Boîte postale")
+    extended_address = models.CharField(max_length=512, blank=True, verbose_name="Adresse complémentaire")
+    street_address = models.CharField(max_length=512, blank=True, verbose_name="Adresse")
+    locality = models.CharField(max_length=512, verbose_name="Ville")
+    region = models.CharField(max_length=512, verbose_name="Région")
+    postal_code = models.CharField(max_length=512, blank=True, verbose_name="Code postal")
+    country_name = models.CharField(max_length=512, verbose_name="Pays")
     isp = models.OneToOneField(ISPInfo)
 
     # not in db.ffdn.org spec
     siret = FRSIRETField('SIRET')
 
+    class Meta:
+        verbose_name = "Siège social"
+        verbose_name_plural = "Sièges sociaux"
+
     def to_dict(self):
         d = dict()
         for field in ('post_office_box', 'extended_address', 'street_address',
@@ -188,21 +217,30 @@ class RegisteredOffice(models.Model):
 
 
 class ChatRoom(models.Model):
-    url = models.CharField(verbose_name="URL", max_length=256)
+    url = models.CharField(
+        verbose_name="URL", max_length=256, validators=[chatroom_url_validator])
     isp = models.ForeignKey(ISPInfo)
 
+    class Meta:
+        verbose_name = "Salon de discussions"
+        verbose_name_plural = "Salons de discussions"
+
 
 class CoveredArea(models.Model):
-    name = models.CharField(max_length=512)
-    # TODO: we must allow multiple values
-    technologies = models.CharField(choices=TECHNOLOGIES, max_length=16)
+    name = models.CharField(max_length=512, verbose_name="Nom")
+
+    technologies = MultiSelectField(choices=TECHNOLOGIES, max_length=42, verbose_name="Technologie")
     # TODO: find a geojson library
     #area =
     isp = models.ForeignKey(ISPInfo)
 
     def to_dict(self):
         return {"name": self.name,
-                "technologies": [self.technologies]}
+                "technologies": self.technologies}
+
+    class Meta:
+        verbose_name = "Zone couverte"
+        verbose_name_plural = "Zones couvertes"
 
 
 class BankInfo(models.Model):

+ 23 - 0
coin/isp_database/tests.py

@@ -1,8 +1,11 @@
+from django.contrib.auth.models import UserManager
 from django.test import TestCase
 
 # Create your tests here.
 
+from coin.members.models import Member
 from coin.isp_database.templatetags.isptags import *
+from .models import ChatRoom, ISPInfo
 
 class TestPrettifiers(TestCase):
     def test_pretty_iban(self):
@@ -16,3 +19,23 @@ class TestPrettifiers(TestCase):
         self.assertEqual(pretty_iban('ADkkBBBBSSSSCCCCCCCCCCCC'),
                          'ADkk BBBB SSSS CCCC CCCC CCCC')
         self.assertEqual(pretty_iban(''), '')
+
+class TestContactPage(TestCase):
+    def setUp(self):
+        # Could be replaced by a force_login when we will be at Django 1.9
+        Member.objects.create_user('user', password='password')
+        self.client.login(username='user', password='password')
+
+    def test_chat_view(self):
+        isp = ISPInfo.objects.create(name='test', email='foo@example.com', )
+
+        # Without chatroom
+        response = self.client.get('/members/contact/')
+        self.assertEqual(response.status_code, 200)
+
+        # With chatroom
+        ChatRoom.objects.create(
+            isp=isp, url='irc://irc.example.com/#chan')
+
+        response = self.client.get('/members/contact/')
+        self.assertEqual(response.status_code, 200)

+ 118 - 44
coin/members/admin.py

@@ -5,15 +5,18 @@ from django.shortcuts import render, get_object_or_404
 from django.contrib import admin
 from django.contrib import messages
 from django.contrib.auth.admin import UserAdmin
-from django.contrib.auth.models import Group
+from django.contrib.auth.models import Group, Permission
+from django.contrib.contenttypes.models import ContentType
 from django.http import HttpResponseRedirect
 from django.conf.urls import url
 from django.db.models.query import QuerySet
 from django.core.urlresolvers import reverse
+from django.utils.html import format_html
 
-from coin.members.models import Member, CryptoKey, LdapUser, MembershipFee
+from coin.members.models import (
+    Member, CryptoKey, LdapUser, MembershipFee, Offer, OfferSubscription, RowLevelPermission)
 from coin.members.membershipfee_filter import MembershipFeeFilter
-from coin.members.forms import MemberChangeForm, MemberCreationForm
+from coin.members.forms import AdminMemberChangeForm, MemberCreationForm
 from coin.utils import delete_selected
 import autocomplete_light
 
@@ -30,73 +33,137 @@ class MembershipFeeInline(admin.TabularInline):
               'reference', 'payment_date')
 
 
+class OfferSubscriptionInline(admin.TabularInline):
+    model = OfferSubscription
+    extra = 0
+
+    writable_fields = ('subscription_date', 'resign_date', 'commitment', 'offer')
+    all_fields = ('get_subscription_reference',) + writable_fields
+
+    def get_fields(self, request, obj=None):
+        if obj:
+            return self.all_fields
+        else:
+            return self.writable_fields
+
+    def get_readonly_fields(self, request, obj=None):
+        # création ou superuser : lecture écriture
+        if not obj or request.user.is_superuser:
+            return ('get_subscription_reference',)
+        # modification : lecture seule seulement
+        else:
+            return self.all_fields
+
+    show_change_link = True
+
+    def formfield_for_foreignkey(self, db_field, request, **kwargs):
+        if request.user.is_superuser:
+            return super(OfferSubscriptionInline, self).formfield_for_foreignkey(db_field, request, **kwargs)
+        else:
+            if db_field.name == "offer":
+                kwargs["queryset"] = Offer.objects.manageable_by(request.user)
+            return super(OfferSubscriptionInline, self).formfield_for_foreignkey(db_field, request, **kwargs)
+
+    def has_add_permission(self, request):
+        # - Quand on *crée* un membre on autorise à ajouter un abonnement
+        # - Quand on *édite* un membre, on interdit l'ajout d'abonnements (sauf
+        #   par le bureau) car cela permettrait de gagner à loisir accès à
+        #   toute fiche adhérent en lui ajoutant un abonnement à une offre dont
+        #   on a la gestion).
+        return (
+            request.resolver_match.view_name == 'admin:members_member_add'
+            or
+            request.user.is_superuser
+        )
+
+    # sinon on pourrait supprimer les abo qu'on ne peut pas gérer
+    # pourrait peut-être être plus fin, obj réfère ici au member de la page
+    def has_delete_permission(self, request, obj=None):
+        return request.user.is_superuser
+
+
 class MemberAdmin(UserAdmin):
     list_display = ('id', 'status', 'username', 'first_name', 'last_name',
                     'nickname', 'organization_name', 'email',
                     'end_date_of_membership')
     list_display_links = ('id', 'username', 'first_name', 'last_name')
     list_filter = ('status', MembershipFeeFilter)
-    search_fields = ['username', 'first_name', 'last_name', 'email']
+    search_fields = ['username', 'first_name', 'last_name', 'email', 'nickname']
     ordering = ('status', 'username')
     actions = [delete_selected, 'set_as_member', 'set_as_non_member',
                'bulk_send_welcome_email', 'bulk_send_call_for_membership_fee_email']
 
-    form = MemberChangeForm
+    form = AdminMemberChangeForm
     add_form = MemberCreationForm
 
-    fieldsets = (
-        ('Adhérent', {'fields': (
-            ('status', 'resign_date'),
-            'type',
-            ('first_name', 'last_name', 'nickname'),
-            'organization_name',
-            'comments')}),
-        ('Coordonnées', {'fields': (
-            'email',
-            ('home_phone_number', 'mobile_phone_number'),
-            'address',
-            ('postal_code', 'city', 'country'))}),
-        ('Authentification', {'fields': (
-            ('username', 'password'))}),
-        ('Permissions', {'fields': (
-            ('is_active', 'is_staff', 'is_superuser'))}),
-        (None, {'fields': ('date_last_call_for_membership_fees_email',)})
-    )
-
-    add_fieldsets = (
-        ('Adhérent', {'fields': (
-            'status',
-            'type',
-            ('first_name', 'last_name', 'nickname'),
-            'organization_name',
-            'comments')}),
-        ('Coordonnées', {'fields': (
-            'email',
+    def get_fieldsets(self, request, obj=None):
+        coord_fieldset = ('Coordonnées', {'fields': (
+            ('email', 'send_membership_fees_email'),
             ('home_phone_number', 'mobile_phone_number'),
             'address',
-            ('postal_code', 'city', 'country'))}),
-        ('Authentification', {'fields': (
-            ('username', 'password'),)}),
-        ('Permissions', {'fields': (
-            ('is_active', 'is_staff', 'is_superuser', 'date_joined'))})
-    )
+            ('postal_code', 'city', 'country'))})
+        auth_fieldset = ('Authentification', {'fields': (
+            ('username', 'password'))})
+        perm_fieldset = ('Permissions', {'fields': (
+            ('is_active', 'is_staff', 'is_superuser', 'groups'))})
+
+        # if obj is null then it is a creation, otherwise it is a modification
+        if obj:
+            return (
+                ('Adhérent', {'fields': (
+                    ('status', 'date_joined', 'resign_date'),
+                    'type',
+                    ('first_name', 'last_name', 'nickname'),
+                    'organization_name',
+                    'comments',
+                    'balance' # XXX we shouldn't need this, the default value should be used
+                )}),
+                coord_fieldset,
+                auth_fieldset,
+                perm_fieldset,
+                (None, {'fields': ('date_last_call_for_membership_fees_email',)})
+            )
+        else:
+            return (
+                ('Adhérent', {'fields': (
+                    ('status', 'date_joined'),
+                    'type',
+                    ('first_name', 'last_name', 'nickname'),
+                    'organization_name',
+                    'comments',
+                    'balance')}),
+                coord_fieldset,
+                auth_fieldset,
+                perm_fieldset
+            )
 
     radio_fields = {"type": admin.HORIZONTAL}
 
     save_on_top = True
 
-    inlines = [CryptoKeyInline, MembershipFeeInline]
+    inlines = [CryptoKeyInline, MembershipFeeInline, OfferSubscriptionInline]
+
+    def get_queryset(self, request):
+        qs = super(MemberAdmin, self).get_queryset(request)
+        if request.user.is_superuser:
+            return qs
+        else:
+            offers = Offer.objects.manageable_by(request.user)
+            return qs.filter(offersubscription__offer__in=offers).distinct()
 
     def get_readonly_fields(self, request, obj=None):
+        readonly_fields = []
         if obj:
             # Remove help_text for readonly field (can't do that in the Form
             # django seems to user help_text from model for readonly fields)
             username_field = [
                 f for f in obj._meta.fields if f.name == 'username']
             username_field[0].help_text = ''
-            return ['username', ]
-        else:
-            return []
+
+            readonly_fields.append('username')
+        if not request.user.is_superuser:
+            readonly_fields += ['is_active', 'is_staff', 'is_superuser', 'groups', 'date_last_call_for_membership_fees_email']
+        return readonly_fields
 
     def set_as_member(self, request, queryset):
         rows_updated = queryset.update(status='member')
@@ -194,7 +261,14 @@ class MembershipFeeAdmin(admin.ModelAdmin):
                     'payment_date')
     form = autocomplete_light.modelform_factory(MembershipFee, fields='__all__')
 
+class RowLevelPermissionAdmin(admin.ModelAdmin):
+    def get_changeform_initial_data(self, request):
+        return {'content_type': ContentType.objects.get_for_model(OfferSubscription)}
+
+
+
 admin.site.register(Member, MemberAdmin)
 admin.site.register(MembershipFee, MembershipFeeAdmin)
-admin.site.unregister(Group)
+# admin.site.unregister(Group)
 # admin.site.register(LdapUser, LdapUserAdmin)
+admin.site.register(RowLevelPermission, RowLevelPermissionAdmin)

+ 10 - 7
coin/members/autocomplete_light_registry.py

@@ -8,10 +8,13 @@ from models import Member
 autocomplete_light.register(Member,
                             # Just like in ModelAdmin.search_fields
                             search_fields=[
-                                '^first_name', 'last_name', 'organization_name',
-                                'username'],
-                            # This will actually data-minimum-characters which
-                            # will set widget.autocomplete.minimumCharacters.
-                            autocomplete_js_attributes={
-                                'placeholder': 'Other model name ?', },
-                            )
+                                '^first_name', '^last_name', 'organization_name',
+                                '^username', '^nickname'],
+                            attrs={
+                                # This will set the input placeholder attribute:
+                                'placeholder': 'Nom/Prénom/Pseudo (min 3 caractères)',
+                                # Nombre minimum de caractères à saisir avant de compléter.
+                                # Fixé à 3 pour ne pas qu'on puisse avoir accès à la liste de tous les membres facilement quand on n'est pas superuser.
+                                'data-autocomplete-minimum-characters': 3,
+                            },
+)

+ 50 - 4
coin/members/forms.py

@@ -3,6 +3,7 @@ from __future__ import unicode_literals
 
 from django import forms
 from django.contrib.auth.forms import PasswordResetForm, ReadOnlyPasswordHashField
+from django.forms.utils import ErrorList
 
 from coin.members.models import Member
 
@@ -37,20 +38,18 @@ class MemberCreationForm(forms.ModelForm):
         return member
 
 
-class MemberChangeForm(forms.ModelForm):
-
+class AbstractMemberChangeForm(forms.ModelForm):
     """
     This form was inspired from django.contrib.auth.forms.UserChangeForm
     and adapted to coin specificities
     """
-    password = ReadOnlyPasswordHashField()
 
     class Meta:
         model = Member
         fields = '__all__'
 
     def __init__(self, *args, **kwargs):
-        super(MemberChangeForm, self).__init__(*args, **kwargs)
+        super(AbstractMemberChangeForm, self).__init__(*args, **kwargs)
         f = self.fields.get('user_permissions', None)
         if f is not None:
             f.queryset = f.queryset.select_related('content_type')
@@ -66,5 +65,52 @@ class MemberChangeForm(forms.ModelForm):
         return self.initial["username"]
 
 
+class AdminMemberChangeForm(AbstractMemberChangeForm):
+    password = ReadOnlyPasswordHashField()
+
+
+class SpanError(ErrorList):
+    def __unicode__(self):
+        return self.as_spans()
+    def __str__(self):
+        return self.as_spans()
+    def as_spans(self):
+        if not self: return ''
+        return ''.join(['<span class="error">%s</span>' % e for e in self])
+
+class PersonMemberChangeForm(AbstractMemberChangeForm):
+    """
+    Form use to allow natural person to change their info
+    """
+    class Meta:
+        model = Member
+        fields = ['first_name', 'last_name', 'email', 'nickname',
+                  'home_phone_number', 'mobile_phone_number',
+                  'address', 'postal_code', 'city', 'country']
+
+    def __init__(self, *args, **kwargs):
+        super(PersonMemberChangeForm, self).__init__(*args, **kwargs)
+        self.error_class = SpanError
+        for fieldname in self.fields:
+            self.fields[fieldname].help_text = None
+
+
+class OrganizationMemberChangeForm(AbstractMemberChangeForm):
+    """
+    Form use to allow organization to change their info
+    """
+    class Meta:
+        model = Member
+        fields = ['organization_name', 'email', 'nickname',
+                  'home_phone_number', 'mobile_phone_number',
+                  'address', 'postal_code', 'city', 'country']
+
+    def __init__(self, *args, **kwargs):
+        super(OrganizationChangeForm, self).__init__(*args, **kwargs)
+        self.error_class = SpanError
+        for fieldname in self.fields:
+            self.fields[fieldname].help_text = None
+
 class MemberPasswordResetForm(PasswordResetForm):
     pass
+

+ 5 - 4
coin/members/management/commands/call_for_membership_fees.py

@@ -14,8 +14,8 @@ from coin.members.models import Member, MembershipFee
 class Command(BaseCommand):
     args = '[date=2011-07-04]'
     help = """Send a call for membership email to members.
-              A mail is sent when end date of membership 
-              reach the anniversary date, 1 month before and once a month 
+              A mail is sent when end date of membership
+              reach the anniversary date, 1 month before and once a month
               for 3 months.
               By default, today is used to compute relative dates, but a date
               can be passed as argument."""
@@ -43,7 +43,8 @@ class Command(BaseCommand):
 
         members = Member.objects.filter(status='member')\
                                 .annotate(end=Max('membership_fees__end_date'))\
-                                .filter(end__in=end_dates)
+                                .filter(end__in=end_dates)\
+                                .filter(send_membership_fees_email=True)
         if verbosity >= 2:
             self.stdout.write(
                 "Got {number} members.".format(number=members.count()))
@@ -51,7 +52,7 @@ class Command(BaseCommand):
         cpt = 0
         with respect_language(settings.LANGUAGE_CODE):
             for member in members:
-                if member.send_call_for_membership_fees_email():
+                if member.send_call_for_membership_fees_email(auto=True):
                     self.stdout.write(
                         'Call for membership fees email was sent to {member} ({email})'.format(
                             member=member, email=member.email))

+ 46 - 3
coin/members/management/commands/members_email.py

@@ -1,15 +1,58 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
+import datetime
+
 from django.core.management.base import BaseCommand, CommandError
+from django.db.models import Q
 
 from coin.members.models import Member
-
+from coin.offers.models import Offer
+from coin.offers.models import OfferSubscription
 
 class Command(BaseCommand):
-    help = 'Returns the email addresses of all members, in a format suitable for bulk importing in Sympa'
+    help = """Returns email addresses of members in a format suitable for bulk importing in Sympa."""
+
+    def add_arguments(self, parser):
+        parser.add_argument('--subscribers', action='store_true',
+                            help='Return only the email addresses of subscribers to any offers.')
+        parser.add_argument('--offer', metavar='OFFER-ID or OFFER-REF',
+                            help='Return only the email addresses of subscribers to the specified offer')
 
     def handle(self, *args, **options):
-        emails = [m.email for m in Member.objects.filter(status='member')]
+        if options['subscribers']:
+            today = datetime.date.today()
+                        
+            offer_subscriptions = OfferSubscription.objects.filter(
+                Q(resign_date__gt=today)
+                | Q(resign_date__isnull=True)
+            )
+            members = [s.member for s in offer_subscriptions]
+        elif options['offer']:
+            try:
+                # Try to find the offer by its reference
+                offer = Offer.objects.get(reference=options['offer'])
+            except Offer.DoesNotExist:
+                try:
+                    # No reference found, maybe it's an offer_id
+                    offer_id = int(options['offer'])
+                    offer = Offer.objects.get(pk=offer_id)
+                except Offer.DoesNotExist:
+                    raise CommandError('Offer "%s" does not exist' % options['offer'])
+                except (IndexError, ValueError):
+                    raise CommandError('Please enter a valid offer reference or id')
+            today = datetime.date.today()
+
+            offer_subscriptions = OfferSubscription.objects.filter(
+                 # Fetch all OfferSubscription to the given Offer
+                Q(offer=offer)
+                # Check if OfferSubscription isn't resigned
+                & (Q(resign_date__isnull=True) | Q(resign_date__gt=today))
+            ).select_related('member')
+            members = [s.member for s in offer_subscriptions]
+        else:
+            members = Member.objects.filter(status='member')
+
+        emails = list(set([m.email for m in members if m.status == 'member']))
         for email in emails:
             self.stdout.write(email)

+ 42 - 0
coin/members/migrations/0013_auto_20161015_1837.py

@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.core.validators
+import django.contrib.auth.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('members', '0012_member_date_last_call_for_membership_fees_email'),
+    ]
+
+    operations = [
+        migrations.AlterModelManagers(
+            name='member',
+            managers=[
+                ('objects', django.contrib.auth.models.UserManager()),
+            ],
+        ),
+        migrations.AlterField(
+            model_name='member',
+            name='email',
+            field=models.EmailField(unique=True, max_length=254, verbose_name='email address'),
+        ),
+        migrations.AlterField(
+            model_name='member',
+            name='groups',
+            field=models.ManyToManyField(related_query_name='user', related_name='user_set', to='auth.Group', blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', verbose_name='groups'),
+        ),
+        migrations.AlterField(
+            model_name='member',
+            name='last_login',
+            field=models.DateTimeField(null=True, verbose_name='last login', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='member',
+            name='username',
+            field=models.CharField(error_messages={'unique': 'A user with that username already exists.'}, max_length=30, validators=[django.core.validators.RegexValidator('^[\\w.@+-]+$', 'Enter a valid username. This value may contain only letters, numbers and @/./+/-/_ characters.', 'invalid')], help_text='Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.', unique=True, verbose_name='username'),
+        ),
+    ]

+ 19 - 0
coin/members/migrations/0014_member_balance.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('members', '0013_auto_20161015_1837'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='member',
+            name='balance',
+            field=models.DecimalField(default=0, verbose_name='account balance', max_digits=5, decimal_places=2),
+        ),
+    ]

+ 19 - 0
coin/members/migrations/0014_member_send_membership_fees_email.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('members', '0013_auto_20161015_1837'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='member',
+            name='send_membership_fees_email',
+            field=models.BooleanField(default=True, help_text="Certains membres n'ont pas \xe0 recevoir de relance (pr\xe9l\xe8vement automatique, membres d'honneurs, etc.)", verbose_name='relance de cotisation'),
+        ),
+    ]

+ 19 - 0
coin/members/migrations/0015_auto_20170824_2308.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('members', '0014_member_send_membership_fees_email'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='member',
+            name='send_membership_fees_email',
+            field=models.BooleanField(default=True, help_text="Pr\xe9cise si l'utilisateur doit recevoir des mails de relance pour la cotisation. Certains membres n'ont pas \xe0 recevoir de relance (pr\xe9l\xe8vement automatique, membres d'honneurs, etc.)", verbose_name='relance de cotisation'),
+        ),
+    ]

+ 15 - 0
coin/members/migrations/0016_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 = [
+        ('members', '0015_auto_20170824_2308'),
+        ('members', '0014_member_balance'),
+    ]
+
+    operations = [
+    ]

+ 25 - 0
coin/members/migrations/0016_rowlevelpermission.py

@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('auth', '0006_require_contenttypes_0002'),
+        ('offers', '0007_offersubscription_comments'),
+        ('members', '0015_auto_20170824_2308'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='RowLevelPermission',
+            fields=[
+                ('permission_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='auth.Permission')),
+                ('description', models.TextField(blank=True)),
+                ('offer', models.ForeignKey(verbose_name='Offre', to='offers.Offer', help_text="Offre dont l'utilisateur est autoris\xe9 \xe0 voir et modifier les membres et les abonnements.", null=True)),
+            ],
+            bases=('auth.permission',),
+        ),
+    ]

+ 15 - 0
coin/members/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 = [
+        ('members', '0016_rowlevelpermission'),
+        ('members', '0016_merge'),
+    ]
+
+    operations = [
+    ]

+ 107 - 16
coin/members/models.py

@@ -9,18 +9,33 @@ from django.db import models
 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
+from django.contrib.auth.models import AbstractUser, Permission, UserManager
+from django.contrib.contenttypes.models import ContentType
 from django.conf import settings
 from django.core.validators import RegexValidator
 from django.core.exceptions import ValidationError
 from django.utils import timezone
+from django.utils.text import slugify
 from ldapdb.models.fields import CharField, IntegerField, ListField
 
-from coin.offers.models import OfferSubscription
+from coin.offers.models import Offer, OfferSubscription
 from coin.mixins import CoinLdapSyncMixin
 from coin import utils
 
 
+
+class MemberManager(UserManager):
+    def manageable_by(self, user):
+        """" Renvoie la liste des members que l'utilisateur est autorisé à voir
+        dans l'interface d'administration.
+        """
+        if user.is_superuser:
+            return super(MemberManager, self).all()
+        else:
+            offers = Offer.objects.manageable_by(user)
+            return super(MemberManager, self).filter(offersubscription__offer__in=offers).distinct()
+
+
 class Member(CoinLdapSyncMixin, AbstractUser):
 
     # USERNAME_FIELD = 'login'
@@ -75,6 +90,13 @@ class Member(CoinLdapSyncMixin, AbstractUser):
     date_last_call_for_membership_fees_email = models.DateTimeField(null=True,
                         blank=True,
                         verbose_name="Date du dernier email de relance de cotisation envoyé")
+    send_membership_fees_email = models.BooleanField(
+        default=True, verbose_name='relance de cotisation',
+        help_text='Précise si l\'utilisateur doit recevoir des mails de relance pour la cotisation. Certains membres n\'ont pas à recevoir de relance (prélèvement automatique, membres d\'honneurs, etc.)')
+    balance = models.DecimalField(max_digits=5, decimal_places=2, default=0,
+                                  verbose_name='account balance')
+
+    objects = MemberManager()
 
     # Following fields are managed by the parent class AbstractUser :
     # username, first_name, last_name, email
@@ -258,27 +280,50 @@ class Member(CoinLdapSyncMixin, AbstractUser):
         """ Envoie le courriel de bienvenue à ce membre """
         from coin.isp_database.models import ISPInfo
 
-        utils.send_templated_email(to=self.email,
-                   subject_template='members/emails/welcome_email_subject.txt',
-                   body_template='members/emails/welcome_email.html',
-                   context={'member': self, 'branding':ISPInfo.objects.first()})
+        isp_info = ISPInfo.objects.first()
+
+        kwargs = {}
+        if isp_info.administrative_email:
+            kwargs['from_email'] = isp_info.administrative_email
+
+        utils.send_templated_email(
+            to=self.email,
+            subject_template='members/emails/welcome_email_subject.txt',
+            body_template='members/emails/welcome_email.html',
+            context={'member': self, 'branding': isp_info},
+            **kwargs)
+
+    def send_call_for_membership_fees_email(self, auto=False):
+        """ Envoie le courriel d'appel à cotisation du membre
+
+        :param auto: is it an auto email? (changes slightly template content)
+        """
+        if auto and not self.send_membership_fees_email:
+            return False
 
-    def send_call_for_membership_fees_email(self):
-        """ Envoi le courriel d'appel à cotisation du membre """
         from dateutil.relativedelta import relativedelta
         from coin.isp_database.models import ISPInfo
 
+        isp_info = ISPInfo.objects.first()
+        kwargs = {}
+        # Il peut ne pas y avir d'ISPInfo, ou bien pas d'administrative_email
+        if isp_info and isp_info.administrative_email:
+            kwargs['from_email'] = isp_info.administrative_email
+
         # Si le dernier courriel de relance a été envoyé il y a moins de trois
         # semaines, n'envoi pas un nouveau courriel
         if (not self.date_last_call_for_membership_fees_email
             or (self.date_last_call_for_membership_fees_email
-               <= timezone.now() + relativedelta(weeks=-3))):
-            utils.send_templated_email(to=self.email,
-               subject_template='members/emails/call_for_membership_fees_subject.txt',
-               body_template='members/emails/call_for_membership_fees.html',
-               context={'member': self, 'branding':ISPInfo.objects.first(),
-                        'membership_info_url': settings.MEMBER_MEMBERSHIP_INFO_URL,
-                        'today': datetime.date.today})
+                <= timezone.now() + relativedelta(weeks=-3))):
+            utils.send_templated_email(
+                to=self.email,
+                subject_template='members/emails/call_for_membership_fees_subject.txt',
+                body_template='members/emails/call_for_membership_fees.html',
+                context={'member': self, 'branding': isp_info,
+                         'membership_info_url': settings.MEMBER_MEMBERSHIP_INFO_URL,
+                         'today': datetime.date.today,
+                         'auto_sent': auto},
+                **kwargs)
             # Sauvegarde en base la date du dernier envoi de mail de relance
             self.date_last_call_for_membership_fees_email = timezone.now()
             self.save()
@@ -408,7 +453,7 @@ class MembershipFee(models.Model):
                                     verbose_name='date du paiement')
 
     def clean(self):
-        if self.end_date is None:
+        if self.start_date is not None and self.end_date is None:
             self.end_date = self.start_date + datetime.timedelta(364)
 
     def __unicode__(self):
@@ -466,6 +511,7 @@ class LdapUser(ldapdb.models.Model):
 # managed = False  # Indique à Django de ne pas intégrer ce model en base
 
 
+
 @receiver(pre_save, sender=Member)
 def define_username(sender, instance, **kwargs):
     """
@@ -485,3 +531,48 @@ def define_display_name(sender, instance, **kwargs):
     if not instance.display_name:
         instance.display_name = '%s %s' % (instance.first_name,
                                            instance.last_name)
+
+
+
+class RowLevelPermission(Permission):
+    offer = models.ForeignKey(
+        'offers.Offer', null=True, verbose_name="Offre",
+        help_text="Offre dont l'utilisateur est autorisé à voir et modifier les membres et les abonnements.")
+    description = models.TextField(blank=True)
+
+    def save(self, *args, **kwargs):
+        """
+        Lors de la sauvegarde d'une RowLevelPermission. Si le champ codename n'est pas définit,
+        le calcul automatiquement.
+        """
+        if not self.codename:
+            self.codename = self.generate_codename()
+        return super(RowLevelPermission, self).save(*args, **kwargs)
+
+    def generate_codename(self):
+        """
+        Calcule le codename automatiquement en fonction du name.
+        """
+        # Convertit en ASCII. Convertit les espaces en tirets. Enlève les caractères qui ne sont ni alphanumériques, ni soulignements, ni tirets. Convertit en minuscules. Les espaces en début et fin de chaîne sont aussi enlevés
+        codename = slugify(self.name)
+        # Maximum de 30 char
+        codename = codename[:30]
+        # Recherche dans les membres existants un codename identique
+        perm = Permission.objects.filter(codename=codename)
+        base_codename = codename
+        incr = 2
+        # Tant qu'une permission est trouvée, incrémente un entier à la fin
+        while perm:
+            codename = base_codename + str(incr)
+            perm = Permission.objects.filter(codename=codename)
+            incr += 1
+        return codename
+
+    class Meta:
+        verbose_name = 'permission fine'
+        verbose_name_plural = 'permissions fines'
+
+
+RowLevelPermission._meta.get_field('codename').blank = True
+RowLevelPermission._meta.get_field('codename').help_text = 'Laisser vide pour le générer automatiquement'
+RowLevelPermission._meta.get_field('content_type').help_text = "Garder 'abonnement' pour une utilisation normale"

+ 21 - 5
coin/members/templates/members/contact.html

@@ -7,17 +7,33 @@
         <div class="panel">
             <h3>Courriel</h3>
             <p>
-              <a href="mailto:{{ branding.email }}">{{ branding.email }}</a> (questions générales)<br />
-              <a href="mailto:{{ branding.administrative_email }}">{{ branding.administrative_email }}</a> (questions administratives)<br />
-              <a href="mailto:{{ branding.support_email }}">{{ branding.support_email }}</a> (support technique)
+                <a href="mailto:{{ branding.email }}">{{ branding.email }}</a> (questions générales)<br />
+                {% if branding.administrative_email %}
+                <a href="mailto:{{ branding.administrative_email }}">{{ branding.administrative_email }}</a> (questions administratives)<br />
+                {% endif %}
+                {% if branding.support_email %}
+                <a href="mailto:{{ branding.support_email }}">{{ branding.support_email }}</a> (support technique)
+                {% endif %}
             </p>
-            {% if branding.lists_url %}
+        </div>
+        {% if branding.lists_url %}
+        <div class="panel">
             <h3>Listes de discussion</h3>
             <p>Gérer ses abonnements aux listes de discussion et diffusion : <a href="{{ branding.lists_url }}">{{ branding.lists_url }}</a></p>
-            {% endif %}
+        </div>
+        {% endif %}
+        {% if branding.main_chat_verbose %}
+        <div class="panel">
             <h3>IRC</h3>
             <p><a href="{{ branding.chatroom_set.first.url }}">{{ branding.main_chat_verbose }}</a></p>
         </div>
+        {% endif %}
+        {% if branding.phone_number %}
+        <div class="panel">
+            <h3>Téléphone</h3>
+            <p><a href="tel:{{ branding.phone_number|cut:' ' }}">{{ branding.phone_number }}</a></p>
+        </div>
+        {% endif %}
     </div>
 </div>
 {% endblock %}

+ 78 - 31
coin/members/templates/members/detail.html

@@ -9,66 +9,113 @@
 <div class="row">
     <div class="large-6 columns">
         <div class="panel">
-            <h3>Me joindre</h3>
-            <table class="full-width">
-                <tr>
-                  {% if user.type == 'natural_person' %}
-                    <td class="center"><span class="label">Prénom - Nom</span></td>
-                    <td>{{user.first_name}} {{user.last_name}}
-                      {% if user.nickname %}
-                      ({{ user.nickname }})
-                      {% endif %}
-                    </td>
-                  {% else %}
-                    <td class="center"><span class="label">Nom de la structure</span></td>
+            <h3>Mes coordonnées</h3>
+            <table id="personnal-info" class="full-width no-border no-background">
+            {% if user.type == 'natural_person' %}
+                {% if user.first_name %}
+                <tr class="first-name">
+                    <td>Prénom</td>
+                    <td>{{user.first_name}}</td>
+                </tr>
+                {% endif %}
+                {% if user.last_name %}
+                <tr class="last-name">
+                    <td>Nom</td>
+                    <td>{{user.last_name}}</td>
+                </tr>
+                {% endif %}
+                {% if user.nickname %}
+                <tr class="nickname">
+                    <td>Pseudo</td>
+                    <td>{{ user.nickname }}</td>
+                </tr>
+                {% endif %}
+            {% else %}
+                <tr class="organization-name">
+                    <td>Nom de la structure</td>
                     <td>{{ user.organization_name }}</td>
-                  {% endif %}
                 </tr>
-                <tr>
-                    <td class="center"><span class="label">Adresse</span></td>
+            {% endif %}
+
+            {% if user.address %}
+                <tr class="address">
+                    <td>Adresse</td>
                     <td>{{user.address}}<br />{{user.postal_code}} {{user.city}}</td>
                 </tr>
-                <tr>
-                    <td class="center"><span class="label">Email</span></td>
+            {% endif %}
+
+            {% if user.email %}
+                <tr class="email">
+                    <td>Email</td>
                     <td>{{user.email}}</td>
                 </tr>
-                <tr>
-                    <td class="center"><span class="label">Téléphone fixe</span></td>
+            {% endif %}
+
+            {% if user.home_phone_number %}
+                <tr class="phone-number">
+                    <td>Téléphone fixe</td>
                     <td>{{user.home_phone_number}}</td>
                 </tr>
-                <tr>
-                    <td class="center"><span class="label">Téléphone mobile</span></td>
+            {% endif %}
+
+            {% if user.mobile_phone_number %}
+                <tr class="mobile-number">
+                    <td>Téléphone mobile</td>
                     <td>{{user.mobile_phone_number}}</td>
                 </tr>
+            {% endif %}
             </table>
         </div>
     </div>
 
     <div class="large-6 columns">
         <div class="panel">
-            <h3>Je suis membre de l'association {{ branding.shortname|capfirst }}</h3>
-            <p>et ma cotisation est :
+            <h3>Membre de {{ branding.shortname|capfirst }}</h3>
+            <p>Ma cotisation est :
                 {% if user.is_paid_up %}
                     <span class="label success">à jour !</span>
                 {% else %}
                     <span class="label alert">non à jour !</span>
                 {% endif %}
             </p>
-            <p>Date de fin de cotisation : {{ user.end_date_of_membership }}</p>
+            <p>
+                {% if  user.end_date_of_membership %}
+                Date de fin de cotisation : {{ user.end_date_of_membership }}
+                {% else %}
+                Je n'ai encore jamais cotisé.
+                {% endif %}
+            </p>
 
-            <a href="{{ membership_info_url }}" target="_blank" class="button small radius expand"><i class="fa fa-heart"></i>
- Renouveler ma cotisation</a>
+            {% if membership_info_url %}
+            <a href="{{ membership_info_url }}" class="button small radius expand">
+                <i class="fa fa-heart"></i> Renouveler ma cotisation
+            </a>
+            {% endif %}
         </div>
-        <!--<div class="pa nel">
-            <h3>Infos additionnelles</h3>
-            <span class="label">Clé ssh</span> coin.pub
-        </div>-->
+
     </div>
 
 </div>
 <div class="row">
     <div class="large-12 columns">
-        <p>Pour modifier vos informations personnelles et vos coordonnées, veuillez en faire la demande par email à <a href="mailto:{{ branding.administrative_email }}">{{ branding.administrative_email }}</a></p>
+        {% if form %}
+            <form method="post" action="">
+                {% csrf_token %}
+                <fieldset class="module aligned wide">
+                {{ form.as_p }}
+                </fieldset>
+                <input type="submit" class="button radius" value="Modifier"/>
+            </form>
+        {% else %}
+            <p>
+                Pour modifier vos informations personnelles et vos coordonnées, veuillez en faire la demande
+                {% if branding.administrative_email %}
+                par email à <a href="mailto:{{ branding.administrative_email }}">{{ branding.administrative_email }}</a>.
+                {% else %}
+                à l'association.
+                {% endif%}
+            </p>
+        {% endif %}
     </div>
 </div>
 

+ 7 - 6
coin/members/templates/members/emails/call_for_membership_fees.html

@@ -1,16 +1,17 @@
 <p>Bonjour {{ member }},</p>
 
-<p>Ta cotisation annuelle à l'association {{ branding.shortname|capfirst }} 
+<p>Ta cotisation annuelle à l'association {{ branding.shortname|capfirst }}
 {% if member.end_date_of_membership >= today %}sera à renouveller à partir du{% else %}est à renouveller depuis le{% endif %} {{ member.end_date_of_membership }}.</p>
 
-<p>Un renouvellement ne necessite pas de remplir une nouvelle fois 
-le formulaire d'adhésion, tu trouveras toutes les instructions 
+<p>Un renouvellement ne nécessite pas de remplir une nouvelle fois
+le formulaire d'adhésion, tu trouveras toutes les instructions
 à cette adresse :<br />
 {{ membership_info_url }}</p>
 
-<p>Ce courriel automatique est envoyé 
-un mois avant la date anniversaire de ton adhésion, 
+{% if auto_sent %}
+<p>Ce courriel automatique est envoyé
+un mois avant la date anniversaire de ton adhésion,
 à la date anniversire et
 une fois par mois pendant les trois mois suivant la date anniversaire.</p>
-
+{% endif %}
 <p>L'équipe de l'association {{ branding.shortname|capfirst }}</p>

+ 29 - 13
coin/members/templates/members/index.html

@@ -9,31 +9,47 @@
 
 <div class="row">
     <div class="large-6 columns">
-        <h3>Alertes</h3>
-        <div class="panel">Ici les news de santé des serveurs, etc.</div>
+        <div class="panel">
+            <h3>Alertes</h3>
+            <p>Ici les news de santé des serveurs, etc.</p>
+        </div>
     </div>
     <div class="large-6 columns">
-        <h3>Stats</h3>
-        <div class="panel">Use MOAR bandwidth !</div>
+        <div class="panel">
+            <h3>Stats</h3>
+            <p>Use MOAR bandwidth !</p>
+        </div>
     </div>
     {% if has_isp_feed %}
     <div class="large-6 columns">
-        <h3>News {{ branding.shortname|capfirst }}</h3>
-        <div class="panel" id="feed_isp"><i class="fa fa-spinner fa-spin"></i>
- Chargement en cours</div>
+        <div class="panel">
+            <h3>News {{ branding.shortname|capfirst }}</h3>
+            <div id="feed_isp">
+            <p>
+                <i class="fa fa-spinner fa-spin"></i>
+                Chargement en cours
+            </p>
+            </div>
+        </div>
     </div>
     {% endif %}
     <div class="large-6 columns">
-        <h3>News de la FFDN</h3>
-        <div class="panel" id="feed_ffdn"><i class="fa fa-spinner fa-spin"></i>
- Chargement en cours</div>
+        <div class="panel">
+            <h3>News de la FFDN</h3>
+            <div id="feed_ffdn">
+            <p>
+                <i class="fa fa-spinner fa-spin"></i>
+                Chargement en cours
+            </p>
+            </div>
+        </div>
     </div>
 </div>
 
 {% endblock %}
 
 
-{% block js %}
+{% block extra_js %}
     {{ block.super }}
     <script>
     $(function() {
@@ -43,7 +59,7 @@
         }).done(function(html) {
             $('#feed_isp').html(html);
         }).fail(function() {
-            $('#feed_ffdn').html('Erreur lors du chargement du flux');
+            $('#feed_isp').html('Erreur lors du chargement du flux');
         });
         {% endif %}
         $.ajax({
@@ -55,4 +71,4 @@
         });
     });
     </script>
-{% endblock js %}
+{% endblock extra_js %}

+ 35 - 1
coin/members/templates/members/invoices.html

@@ -1,6 +1,9 @@
 {% extends "base.html" %}
 
 {% block content %}
+
+<h2>Balance : {{ balance|floatformat }} €</h2>
+
 <h2>Mes factures</h2>
 
 <table id="member_invoices" class="full-width">
@@ -20,10 +23,41 @@
             <td>{{ invoice.date }}</td>
             <td>{{ invoice.amount }}</td>
             <td{% if invoice.amount_remaining_to_pay > 0 %} class="unpaid"{% endif %}>{{ invoice.amount_remaining_to_pay }}</td>
-            <td>{% if invoice.is_pdf_exists %}<a href="{% url 'billing:invoice_pdf' id=invoice.number %}"><i class="fa fa-file-pdf-o"></i> PDF</a>{% endif %}</td>
+            <td>{% if invoice.validated %}<a href="{% url 'billing:invoice_pdf' id=invoice.number %}"><i class="fa fa-file-pdf-o"></i> PDF</a>{% endif %}</td>
+        </tr>
+        {% empty %}
+        <tr class="placeholder"><td colspan="6">Aucune facture.</td></tr>
+        {% endfor %}
+    </tbody>
+</table>
+
+
+<h2>Mes paiements</h2>
+
+<table id="member_payments" class="full-width">
+    <thead>
+        <tr>
+            <th>Date</th>
+            <th>Montant</th>
+            <th>Alloué</th>
         </tr>
+    </thead>
+    <tbody>
+        {% for payment in payments %}
+        <tr>
+            <td>{{ payment.date }}</td>
+            <td>{{ payment.amount }}</td>
+            <td>{{ payment.amount_already_allocated }}</td>
+        </tr>
+        {% empty %}
+        <tr class="placeholder"><td colspan="6">Aucun paiement.</td></tr>
         {% endfor %}
     </tbody>
 </table>
 
+
+<h2>Coordonnées bancaires</h2>
+<div id="payment-howto" class="panel">
+    {% include "billing/payment_howto.html" %}
+</div>
 {% endblock %}

+ 19 - 19
coin/members/templates/members/registration/login.html

@@ -7,39 +7,39 @@
     <div class="medium-7 columns">
         <h2>Connexion</h2>
 
-        <form method="post" action="{% url 'members:django.contrib.auth.views.login' %}">
+        <form id="login-form" method="post" action="{% url 'members:django.contrib.auth.views.login' %}">
             {% csrf_token %}
             {% if form.errors %}
-        	<div class="alert-box alert">
+            <div class="alert-box alert">
                 {% if form.errors.items|length == 1 %}
                     {% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}
                 {% endif %}<br/>
                 {% for error in form.non_field_errors %}{{ error|escape }}{% endfor %}
             </div>
-        	{% endif %}
+            {% endif %}
 
-        	<table width="100%">
-            	<tr>
-            		<td>{{ form.username.label_tag }}</td>
-            		<td>{{ form.username }}
+            <table width="100%">
+                <tr>
+                    <td>{{ form.username.label_tag }}</td>
+                    <td>{{ form.username }}
                         {% if form.username.errors %}
-                            <span class="error">{% for error in form.username.errors %}{{ error|escape }}{% endfor %}</span>
+                        <span class="error">{% for error in form.username.errors %}{{ error|escape }}{% endfor %}</span>
                         {% endif %}
                     </td>
-            	</tr>
-            	<tr>
-            		<td>{{ form.password.label_tag }}
-                    <small><a href="{% url 'members:password_reset' %}" tabindex="100">Mot de passe oublié ?</a></small></td>
-            		<td>{{ form.password }}
+                </tr>
+                <tr>
+                    <td>{{ form.password.label_tag }}</td>
+                    <td>{{ form.password }}
                         {% if form.password.errors %}
-                            <span class="error">{% for error in form.password.errors %}{{ error|escape }}{% endfor %}</span>
+                        <span class="error">{% for error in form.password.errors %}{{ error|escape }}{% endfor %}</span>
                         {% endif %}
                     </td>
-            	</tr>
-        	</table>
-        	<input type="submit" value="Coinnexion" class="button radius"/>
-        	<input type="hidden" name="next" value="{{ next }}" />
-    	</form>
+                </tr>
+            </table>
+            <input type="submit" value="Connexion" class="button radius"/>
+            <input type="hidden" name="next" value="{{ next }}" />
+            <a id="password-reset-link" href="{% url 'members:password_reset' %}" tabindex="100">Mot de passe oublié ?</a>
+        </form>
     </div>
     <div class="medium-5 columns">
         <div class="panel callout" id="newcomers">

+ 1 - 1
coin/members/templates/members/registration/password_reset_form.html

@@ -29,7 +29,7 @@
 </div>
 {% endblock %}
 
-{% block js %}
+{% block extra_js %}
     <script>
         $(function(){
             //On récupère l'email passé éventuellement en paramètre GET.

+ 109 - 72
coin/members/tests.py

@@ -7,17 +7,21 @@ import ldapdb
 from datetime import date
 from cStringIO import StringIO
 from dateutil.relativedelta import relativedelta
+import unittest
 
 from django import db
+from django.conf import settings
 from django.test import TestCase, Client, override_settings
 from django.contrib.auth.models import User
 from django.core import mail, management
+from django.core.exceptions import ValidationError
 
 from coin.members.models import Member, MembershipFee, LdapUser
+from coin.validation import chatroom_url_validator
 
 
-class MemberTests(TestCase):
-
+@unittest.skipIf(not settings.LDAP_ACTIVATE, "LDAP disabled")
+class LDAPMemberTests(TestCase):
     def test_when_creating_member_a_ldapuser_is_also_created_with_same_data(self):
         """
         Test que lors de la création d'un nouveau membre, une entrée
@@ -161,71 +165,6 @@ class MemberTests(TestCase):
 
         member.delete()
 
-    def test_when_creating_member_username_is_well_defined(self):
-        """
-        Lors de la création d'un membre, le champ "username", s'il n'est pas
-        définit doit être généré avec les contraintes suivantes :
-        premières lettres du prénom + nom le tout en minuscule,
-        sans caractères accentués et sans espaces.
-        """
-        random = os.urandom(4).encode('hex')
-        first_name = 'Gérard-Étienne'
-        last_name = 'Majax de la Boétie!B' + random
-
-        control = 'gemajaxdelaboetieb' + random
-        control = control[:30]
-
-        member = Member(first_name=first_name, last_name=last_name)
-        member.save()
-
-        self.assertEqual(member.username, control)
-
-        member.delete()
-
-    def test_when_creating_member_with_username_already_exists_username_is_incr(self):
-        """
-        Lors de la création d'un membre, test si le username existe déjà,
-        renvoi avec un incrément à la fin
-        """
-        random = os.urandom(4).encode('hex')
-
-        member1 = Member(first_name='Hervé', last_name='DUPOND' + random, email='hdupond@coin.org')
-        member1.save()
-        self.assertEqual(member1.username, 'hdupond' + random)
-        
-        member2 = Member(first_name='Henri', last_name='DUPOND' + random, email='hdupond2@coin.org')
-        member2.save()
-        self.assertEqual(member2.username, 'hdupond' + random + '2')
-        
-        member3 = Member(first_name='Hector', last_name='DUPOND' + random, email='hdupond3@coin.org')
-        member3.save()
-        self.assertEqual(member3.username, 'hdupond' + random + '3')
-
-        member1.delete()
-        member2.delete()
-        member3.delete()
-
-    def test_when_creating_legal_entity_organization_name_is_used_for_username(self):
-        """
-        Lors de la créatio d'une entreprise, son nom doit être utilisée lors de
-        la détermination automatique du username
-        """
-        random = os.urandom(4).encode('hex')
-        member = Member(type='legal_entity', organization_name='ILLYSE' + random, email='illyse@coin.org')
-        member.save()
-        self.assertEqual(member.username, 'illyse' + random)
-        member.delete()
-
-    def test_when_creating_member_with_nickname_it_is_used_for_username(self):
-        """
-        Lors de la création d'une personne, qui a un pseudo, celui-ci est utilisé en priorité
-        """
-        random = os.urandom(4).encode('hex')
-        member = Member(first_name='Richard', last_name='Stallman', nickname='rms' + random, email='illyse@coin.org')
-        member.save()
-        self.assertEqual(member.username, 'rms' + random)
-
-        member.delete()
 
     def test_when_saving_member_and_ldap_fail_dont_save(self):
         """
@@ -291,6 +230,74 @@ class MemberTests(TestCase):
 
     #     LdapUser.objects.get(pk=member.username).delete()
 
+
+class MemberTests(TestCase):
+    def test_when_creating_member_username_is_well_defined(self):
+        """
+        Lors de la création d'un membre, le champ "username", s'il n'est pas
+        définit doit être généré avec les contraintes suivantes :
+        premières lettres du prénom + nom le tout en minuscule,
+        sans caractères accentués et sans espaces.
+        """
+        random = os.urandom(4).encode('hex')
+        first_name = 'Gérard-Étienne'
+        last_name = 'Majax de la Boétie!B' + random
+
+        control = 'gemajaxdelaboetieb' + random
+        control = control[:30]
+
+        member = Member(first_name=first_name, last_name=last_name)
+        member.save()
+
+        self.assertEqual(member.username, control)
+
+        member.delete()
+
+    def test_when_creating_member_with_username_already_exists_username_is_incr(self):
+        """
+        Lors de la création d'un membre, test si le username existe déjà,
+        renvoi avec un incrément à la fin
+        """
+        random = os.urandom(4).encode('hex')
+
+        member1 = Member(first_name='Hervé', last_name='DUPOND' + random, email='hdupond@coin.org')
+        member1.save()
+        self.assertEqual(member1.username, 'hdupond' + random)
+
+        member2 = Member(first_name='Henri', last_name='DUPOND' + random, email='hdupond2@coin.org')
+        member2.save()
+        self.assertEqual(member2.username, 'hdupond' + random + '2')
+
+        member3 = Member(first_name='Hector', last_name='DUPOND' + random, email='hdupond3@coin.org')
+        member3.save()
+        self.assertEqual(member3.username, 'hdupond' + random + '3')
+
+        member1.delete()
+        member2.delete()
+        member3.delete()
+
+    def test_when_creating_legal_entity_organization_name_is_used_for_username(self):
+        """
+        Lors de la créatio d'une entreprise, son nom doit être utilisée lors de
+        la détermination automatique du username
+        """
+        random = os.urandom(4).encode('hex')
+        member = Member(type='legal_entity', organization_name='ILLYSE' + random, email='illyse@coin.org')
+        member.save()
+        self.assertEqual(member.username, 'illyse' + random)
+        member.delete()
+
+    def test_when_creating_member_with_nickname_it_is_used_for_username(self):
+        """
+        Lors de la création d'une personne, qui a un pseudo, celui-ci est utilisé en priorité
+        """
+        random = os.urandom(4).encode('hex')
+        member = Member(first_name='Richard', last_name='Stallman', nickname='rms' + random, email='illyse@coin.org')
+        member.save()
+        self.assertEqual(member.username, 'rms' + random)
+
+        member.delete()
+
     def test_member_end_date_of_memberhip(self):
         """
         Test que end_date_of_membership d'un membre envoi bien la date de fin d'adhésion
@@ -334,7 +341,7 @@ class MemberTests(TestCase):
 
         # Créé une cotisation passée
         membershipfee = MembershipFee(member=member, amount=20,
-                                      start_date=date.today() + 
+                                      start_date=date.today() +
                                       relativedelta(years=-1),
                                       end_date=date.today() + relativedelta(days=-10))
         membershipfee.save()
@@ -344,7 +351,7 @@ class MemberTests(TestCase):
 
         # Créé une cotisation actuelle
         membershipfee = MembershipFee(member=member, amount=20,
-                                      start_date=date.today() + 
+                                      start_date=date.today() +
                                       relativedelta(days=-10),
                                       end_date=date.today() + relativedelta(days=+10))
         membershipfee.save()
@@ -358,10 +365,14 @@ class MemberTests(TestCase):
         (prenom, nom) ou pseudo ou nom d'organization
         """
         member = Member(username='blop')
-        self.assertRaises(Exception, member.save)
+        with self.assertRaises(Exception):
+            member.full_clean()
+            member.save()
+
+        with self.assertRaises(Exception):
+            member.full_clean()
+            member.save()
 
-        member = Member()
-        self.assertRaises(Exception, member.save)
 
 
 class MemberAdminTests(TestCase):
@@ -491,3 +502,29 @@ class MemberTestsUtils(object):
         Renvoi une clé aléatoire pour un utilisateur LDAP
         """
         return 'coin_test_' + os.urandom(8).encode('hex')
+
+
+class TestValidators(TestCase):
+    def test_valid_chatroom(self):
+        chatroom_url_validator('irc://irc.example.com/#chan')
+        with self.assertRaises(ValidationError):
+            chatroom_url_validator('http://#faimaison@irc.geeknode.org')
+
+
+class MembershipFeeTests(TestCase):
+    def test_mandatory_start_date(self):
+        member = Member(first_name='foo', last_name='foo', password='foo', email='foo')
+        member.save()
+
+        # If there is no start_date clean_fields() should raise an
+        # error but not clean().
+        membershipfee = MembershipFee(member=member)
+        self.assertRaises(ValidationError, membershipfee.clean_fields)
+        self.assertIsNone(membershipfee.clean())
+
+        # If there is a start_date, everything is fine.
+        membershipfee = MembershipFee(member=member, start_date=date.today())
+        self.assertIsNone(membershipfee.clean_fields())
+        self.assertIsNone(membershipfee.clean())
+
+        member.delete()

+ 28 - 6
coin/members/views.py

@@ -2,11 +2,11 @@
 from __future__ import unicode_literals
 
 from django.template import RequestContext
-from django.shortcuts import render_to_response
+from django.shortcuts import render_to_response, render
 from django.contrib.auth.decorators import login_required
 from django.http import Http404
 from django.conf import settings
-
+from forms import PersonMemberChangeForm, OrganizationMemberChangeForm
 
 @login_required
 def index(request):
@@ -18,10 +18,28 @@ def index(request):
 
 @login_required
 def detail(request):
+
     membership_info_url = settings.MEMBER_MEMBERSHIP_INFO_URL
-    return render_to_response('members/detail.html',
-                              {'membership_info_url': membership_info_url},
-                              context_instance=RequestContext(request))
+    context={
+        'membership_info_url': membership_info_url,
+    }
+
+    if settings.MEMBER_CAN_EDIT_PROFILE:
+        if request.user.type == "natural_person":
+            form_cls = PersonMemberChangeForm
+        else:
+            form_cls = OrganizationMemberChangeForm
+
+        if request.method == "POST":
+            form = form_cls(data = request.POST, instance = request.user)
+            if form.is_valid():
+                form.save()
+        else:
+            form = form_cls(instance = request.user)
+
+        context['form'] = form
+
+    return render(request, 'members/detail.html', context)
 
 
 @login_required
@@ -37,10 +55,14 @@ def subscriptions(request):
 
 @login_required
 def invoices(request):
+    balance  = request.user.balance
     invoices = request.user.invoices.filter(validated=True).order_by('-date')
+    payments = request.user.payments.filter().order_by('-date')
 
     return render_to_response('members/invoices.html',
-                              {'invoices': invoices},
+                              {'balance' : balance, 
+                               'invoices': invoices, 
+                               'payments': payments},
                               context_instance=RequestContext(request))
 
 

+ 33 - 7
coin/offers/admin.py

@@ -2,8 +2,10 @@
 from __future__ import unicode_literals
 
 from django.contrib import admin
+from django.db.models import Q
 from polymorphic.admin import PolymorphicChildModelAdmin
 
+from coin.members.models import Member
 from coin.offers.models import Offer, OfferSubscription
 from coin.offers.offersubscription_filter import\
             OfferSubscriptionTerminationFilter,\
@@ -13,7 +15,7 @@ import autocomplete_light
 
 
 class OfferAdmin(admin.ModelAdmin):
-    list_display = ('get_configuration_type_display', 'name', 'billing_period', 'period_fees',
+    list_display = ('get_configuration_type_display', 'name', 'reference', 'billing_period', 'period_fees',
                     'initial_fees')
     list_display_links = ('name',)
     list_filter = ('configuration_type',)
@@ -28,22 +30,46 @@ class OfferAdmin(admin.ModelAdmin):
 
 
 class OfferSubscriptionAdmin(admin.ModelAdmin):
-    list_display = ('member', 'offer', 'subscription_date', 'commitment',
-                    'resign_date')
+    list_display = ('get_subscription_reference', 'member', 'offer',
+                    'subscription_date', 'commitment', 'resign_date')
     list_display_links = ('member','offer')
     list_filter = ( OfferSubscriptionTerminationFilter,
                     OfferSubscriptionCommitmentFilter,
-                    'member', 'offer')
-    search_fields = ['member__first_name', 'member__last_name', 'member__email']
+                    'offer', 'member')
+    search_fields = ['member__first_name', 'member__last_name', 'member__email',
+                     'member__nickname']
     
     fields = (
                 'member',
                 'offer',
                 'subscription_date',
                 'commitment',
-                'resign_date'
+                'resign_date',
+                'comments'
              )
-    form = autocomplete_light.modelform_factory(OfferSubscription, fields='__all__')
+    # Si c'est un super user on renvoie un formulaire avec tous les membres et toutes les offres (donc autocomplétion pour les membres)
+    def get_form(self, request, obj=None, **kwargs):
+        if request.user.is_superuser:
+            kwargs['form'] = autocomplete_light.modelform_factory(OfferSubscription, fields='__all__')
+        return super(OfferSubscriptionAdmin, self).get_form(request, obj, **kwargs)
+
+    # Si pas super user on restreint les membres et offres accessibles
+    def formfield_for_foreignkey(self, db_field, request, **kwargs):
+        if not request.user.is_superuser:
+            if db_field.name == "member":
+                kwargs["queryset"] = Member.objects.manageable_by(request.user)
+            if db_field.name == "offer":
+                kwargs["queryset"] = Offer.objects.filter(id__in=[p.id for p in Offer.objects.manageable_by(request.user)])
+        return super(OfferSubscriptionAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)
+
+    # Si pas super user on restreint la liste des offres que l'on peut voir
+    def get_queryset(self, request):
+        qs = super(OfferSubscriptionAdmin, self).get_queryset(request)
+        if request.user.is_superuser:
+            return qs
+        else:
+            offers = Offer.objects.manageable_by(request.user)
+            return qs.filter(offer__in=offers)
 
     def get_inline_instances(self, request, obj=None):
         """

+ 44 - 0
coin/offers/management/commands/offer_subscriptions_count.py

@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from optparse import make_option
+import datetime
+
+from django.core.management.base import BaseCommand, CommandError
+from django.db.models import Q, Count
+
+from coin.offers.models import Offer, OfferSubscription
+
+
+BOLD_START = '\033[1m'
+BOLD_END = '\033[0m'
+
+class Command(BaseCommand):
+    option_list = BaseCommand.option_list + (
+        make_option('--date', action='store', dest='date',
+                default=datetime.date.today(), help='Specifies the date to use. Format is YYYY-MM-DD. Default is "today".'),
+    )
+    help = "Return subscription count for each offer type"
+
+    def handle(self, *args, **options):
+        # Get date option
+        date = options.get('date')
+
+        # Validate date type
+        if type(date) is not datetime.date:
+            try:
+                datetime.datetime.strptime(date, '%Y-%m-%d')
+            except ValueError, TypeError:
+                raise CommandError("Incorrect date format, should be YYYY-MM-DD")
+
+        # Count offer subscription
+        offers = Offer.objects\
+                      .filter(Q(offersubscription__subscription_date__lte=date) & (Q(offersubscription__resign_date__gt=date) | Q(offersubscription__resign_date__isnull=True)))\
+                      .annotate(num_subscribtions=Count('offersubscription'))\
+                      .order_by('name')
+
+        # Print count by offer type
+        for offer in offers:
+            self.stdout.write("{offer} offer has {count} subscriber(s)".format(
+                offer=BOLD_START + offer.name + BOLD_END,
+                count=BOLD_START + str(offer.num_subscribtions) + BOLD_END))

+ 20 - 0
coin/offers/migrations/0006_offer_reference.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('offers', '0005_auto_20150210_0923'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='offer',
+            name='reference',
+            field=models.CharField(help_text='Identifiant a utiliser par exemple comme identifiant de virement', max_length=255, blank=True, verbose_name="référence de l'offre"),
+            preserve_default=True,
+        ),
+    ]

+ 19 - 0
coin/offers/migrations/0007_offersubscription_comments.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('offers', '0006_offer_reference'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='offersubscription',
+            name='comments',
+            field=models.TextField(help_text="Commentaires libres (informations sp\xe9cifiques concernant l'abonnement)", verbose_name='commentaires', blank=True),
+        ),
+    ]

+ 53 - 1
coin/offers/models.py

@@ -3,11 +3,28 @@ from __future__ import unicode_literals
 
 import datetime
 
+from django.conf import settings
 from django.db import models
-from django.db.models import Q
+from django.db.models import Count, Q
 from django.core.validators import MinValueValidator
+from django.contrib.contenttypes.models import ContentType
 
 
+class OfferManager(models.Manager):
+    def manageable_by(self, user):
+        """" Renvoie la liste des offres dont l'utilisateur est autorisé à
+        voir les membres et les abonnements dans l'interface d'administration.
+        """
+        from coin.members.models import RowLevelPermission
+        # toutes les permissions appliquées à cet utilisateur
+        # (liste de chaines de caractères)
+        perms = user.get_all_permissions()
+        allowedcodenames = [ s.split('offers.',1)[1] for s in perms if s.startswith('offers.')]
+        # parmi toutes les RowLevelPermission, celles qui sont relatives à des OfferSubscription et qui sont dans allowedcodenames
+        rowperms = RowLevelPermission.objects.filter(content_type=ContentType.objects.get_for_model(OfferSubscription), codename__in=allowedcodenames)
+        # toutes les Offers pour lesquelles il existe une RowLevelpermission correspondante dans rowperms
+        return super(OfferManager, self).filter(rowlevelpermission__in=rowperms).distinct()
+
 class Offer(models.Model):
     """Description of an offer available to subscribers.
 
@@ -19,6 +36,9 @@ class Offer(models.Model):
 
     name = models.CharField(max_length=255, blank=False, null=False,
                             verbose_name="nom de l'offre")
+    reference = models.CharField(max_length=255, blank=True,
+                                    verbose_name="référence de l'offre",
+                                    help_text="Identifiant a utiliser par exemple comme identifiant de virement")
     configuration_type = models.CharField(max_length=50,
                             blank=True,
                             verbose_name='type de configuration',
@@ -40,6 +60,8 @@ class Offer(models.Model):
                                        verbose_name='n\'est pas facturable',
                                        help_text='L\'offre ne sera pas facturée par la commande charge_members')
 
+    objects = OfferManager()
+
     def get_configuration_type_display(self):
         """
         Renvoi le nom affichable du type de configuration
@@ -75,6 +97,27 @@ class Offer(models.Model):
         verbose_name = 'offre'
 
 
+class OfferSubscriptionQuerySet(models.QuerySet):
+    def running(self, at_date=None):
+        """ Only the running contracts at a given date.
+
+        Running mean already started and not stopped yet
+        """
+
+        if at_date is None:
+            at_date = datetime.date.today()
+
+        return self.filter(Q(subscription_date__lte=at_date) &
+                           (Q(resign_date__gt=at_date) |
+                            Q(resign_date__isnull=True)))
+
+    def offer_summary(self):
+        """ Agregates as a count of subscriptions per offer
+        """
+        return self.values('offer__name', 'offer__reference').annotate(
+            num_subscriptions=Count('offer'))
+
+
 class OfferSubscription(models.Model):
     """Only contains administrative details about a subscription, not
     technical.  Nothing here should end up into the LDAP backend.
@@ -100,9 +143,18 @@ class OfferSubscription(models.Model):
                                      help_text='en mois',
                                      validators=[MinValueValidator(0)],
                                      default=0)
+    comments = models.TextField(blank=True, verbose_name='commentaires',
+                                help_text="Commentaires libres (informations"
+                                " spécifiques concernant l'abonnement)")
     member = models.ForeignKey('members.Member', verbose_name='membre')
     offer = models.ForeignKey('Offer', verbose_name='offre')
 
+    objects = OfferSubscriptionQuerySet().as_manager()
+
+    def get_subscription_reference(self):
+        return settings.SUBSCRIPTION_REFERENCE.format(subscription=self)
+    get_subscription_reference.short_description = 'Référence'
+
     def __unicode__(self):
         return '%s - %s - %s' % (self.member, self.offer.name,
                                        self.subscription_date)

+ 2 - 1
coin/offers/urls.py

@@ -2,10 +2,11 @@
 from __future__ import unicode_literals
 
 from django.conf.urls import patterns, url
-from coin.offers.views import ConfigurationRedirectView
+from coin.offers.views import ConfigurationRedirectView, subscription_count_json
 
 urlpatterns = patterns(
     '',
     # Redirect to the appropriate configuration backend.
     url(r'^configuration/(?P<id>.+)$', ConfigurationRedirectView.as_view(), name="configuration-redirect"),
+    url(r'^api/v1/count$', subscription_count_json),
 )

+ 34 - 2
coin/offers/views.py

@@ -1,12 +1,17 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
+import datetime
+import json
+
+from django.db.models import Q, Count
 from django.views.generic.base import RedirectView
 from django.shortcuts import get_object_or_404
 from django.core.urlresolvers import reverse
+from django.http import JsonResponse, HttpResponseServerError
+# from django.views.decorators.cache import cache_control
 
-from coin.offers.models import OfferSubscription
-
+from coin.offers.models import Offer, OfferSubscription
 
 class ConfigurationRedirectView(RedirectView):
     """Redirects to the appropriate view for the configuration backend of the
@@ -19,3 +24,30 @@ class ConfigurationRedirectView(RedirectView):
                                          member=self.request.user)
         return reverse(subscription.configuration.url_namespace + ':' + subscription.configuration.backend_name,
                        args=[subscription.configuration.pk])
+
+
+# @cache_control(max_age=7200)
+def subscription_count_json(request):
+    output = []
+
+    # Get date form url, or set default
+    date = request.GET.get('date', datetime.date.today())
+
+    # Validate date type
+    if not isinstance(date, datetime.date):
+        try:
+            datetime.datetime.strptime(date, '%Y-%m-%d')
+        except ValueError, TypeError:
+            return HttpResponseServerError("Incorrect date format, should be YYYY-MM-DD")
+
+    # Get current offer subscription
+    offersubscriptions = list(OfferSubscription.objects.running(date).offer_summary())
+    for offersub in offersubscriptions:
+        output.append({
+            'reference' : offersub['offer__reference'],
+            'name' : offersub['offer__name'],
+            'subscriptions_count' : offersub['num_subscriptions']
+        })
+
+    # Return JSON
+    return JsonResponse(output, safe=False)

+ 2 - 2
coin/resources/migrations/0001_initial.py

@@ -20,7 +20,7 @@ class Migration(migrations.Migration):
                 ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
                 ('name', models.CharField(help_text="Nom du pool d'IP", max_length=255, verbose_name='nom')),
                 ('default_subnetsize', models.PositiveSmallIntegerField(help_text='Taille par d\xe9faut du sous-r\xe9seau \xe0 allouer aux abonn\xe9s dans ce pool', verbose_name='taille de sous-r\xe9seau par d\xe9faut', validators=[django.core.validators.MaxValueValidator(64)])),
-                ('inet', netfields.fields.CidrAddressField(help_text="Bloc d'adresses IP du pool", max_length=43, verbose_name='r\xe9seau', validators=[coin.resources.models.validate_subnet])),
+                ('inet', netfields.fields.CidrAddressField(help_text="Bloc d'adresses IP du pool", max_length=43, verbose_name='r\xe9seau')),
             ],
             options={
                 'verbose_name': "pool d'IP",
@@ -32,7 +32,7 @@ class Migration(migrations.Migration):
             name='IPSubnet',
             fields=[
                 ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
-                ('inet', netfields.fields.CidrAddressField(blank=True, help_text='Laisser vide pour allouer automatiquement', max_length=43, verbose_name='sous-r\xe9seau', validators=[coin.resources.models.validate_subnet])),
+                ('inet', netfields.fields.CidrAddressField(blank=True, help_text='Laisser vide pour allouer automatiquement', max_length=43, verbose_name='sous-r\xe9seau')),
                 ('delegate_reverse_dns', models.BooleanField(default=False, help_text='D\xe9l\xe9guer la r\xe9solution DNS inverse de ce sous-r\xe9seau \xe0 un ou plusieurs serveurs de noms', verbose_name='d\xe9l\xe9guer le reverse DNS')),
                 ('configuration', models.ForeignKey(related_name='ip_subnet', verbose_name='configuration', to='configuration.Configuration')),
                 ('ip_pool', models.ForeignKey(verbose_name="pool d'IP", to='resources.IPPool')),

+ 1 - 1
coin/resources/migrations/0003_auto_20150203_1043.py

@@ -16,7 +16,7 @@ class Migration(migrations.Migration):
         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'),
+            field=netfields.fields.CidrAddressField(max_length=43, blank=True, help_text='Laisser vide pour allouer automatiquement', unique=True, verbose_name='sous-r\xe9seau'),
             preserve_default=True,
         ),
     ]

+ 15 - 17
coin/resources/models.py

@@ -4,18 +4,8 @@ from __future__ import unicode_literals
 from django.db import models
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator
-from django.db.models import Q
 from netfields import CidrAddressField, NetManager
-from netaddr import IPNetwork, IPSet
-
-
-def validate_subnet(cidr):
-    """Checks that a CIDR object is indeed a subnet, i.e. the host bits are
-    all set to zero."""
-    if not isinstance(cidr, IPNetwork):
-        raise ValidationError("Erreur, objet IPNetwork attendu.")
-    if cidr.ip != cidr.network:
-        raise ValidationError("{} n'est pas un sous-réseau valide, voulez-vous dire {} ?".format(cidr, cidr.cidr))
+from netaddr import IPSet
 
 
 class IPPool(models.Model):
@@ -27,8 +17,7 @@ class IPPool(models.Model):
                                                           verbose_name='taille de sous-réseau par défaut',
                                                           help_text='Taille par défaut du sous-réseau à allouer aux abonnés dans ce pool',
                                                           validators=[MaxValueValidator(64)])
-    inet = CidrAddressField(validators=[validate_subnet],
-                            verbose_name='réseau',
+    inet = CidrAddressField(verbose_name='réseau',
                             help_text="Bloc d'adresses IP du pool")
     objects = NetManager()
 
@@ -55,7 +44,7 @@ class IPPool(models.Model):
 
 
 class IPSubnet(models.Model):
-    inet = CidrAddressField(blank=True, validators=[validate_subnet],
+    inet = CidrAddressField(blank=True,
                             unique=True, verbose_name="sous-réseau",
                             help_text="Laisser vide pour allouer automatiquement")
     objects = NetManager()
@@ -95,9 +84,18 @@ class IPSubnet(models.Model):
         if not self.inet in self.ip_pool.inet:
             raise ValidationError("Le sous-réseau doit être inclus dans le bloc d'IP.")
         # Check that we don't conflict with existing subnets.
-        conflicting = self.ip_pool.ipsubnet_set.filter(Q(inet__net_contained_or_equal=self.inet) |
-                                                       Q(inet__net_contains_or_equals=self.inet)).exclude(id=self.id)
-        if conflicting:
+
+        # The optimal request would be the following commented request, but
+        # django-netfields 0.4.x seems buggy with Q-expressions. For now use
+        # two requests, but the optimal solution will have to be retried once
+        # we use django-netfields>=0.7
+
+        #conflicting = self.ip_pool.ipsubnet_set.filter(Q(inet__net_contained_or_equal=self.inet) |
+        #                                               Q(inet__net_contains_or_equals=self.inet)).exclude(id=self.id)
+        conflicting_contained = self.ip_pool.ipsubnet_set.filter(inet__net_contained_or_equal=self.inet).exclude(id=self.id)
+        conflicting_containing = self.ip_pool.ipsubnet_set.filter(inet__net_contains_or_equals=self.inet).exclude(id=self.id)
+        if conflicting_contained or conflicting_containing:
+            conflicting = conflicting_contained if conflicting_contained else conflicting_containing
             raise ValidationError("Le sous-réseau est en conflit avec des sous-réseaux existants: {}.".format(conflicting))
 
     def validate_reverse_dns(self):

+ 4 - 246
coin/settings.py

@@ -1,254 +1,12 @@
 # -*- coding: utf-8 -*-
-from __future__ import unicode_literals
 
-import os
-import ldap
+from settings_base import *
 
-# Django settings for coin project.
-
-# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
-BASE_DIR = os.path.dirname(os.path.dirname(__file__))
-
-PROJECT_PATH = os.path.abspath(os.path.dirname(__file__))
-DEBUG = TEMPLATE_DEBUG = False
-
-ADMINS = (
-    # ('Your Name', 'your_email@example.com'),
-)
-
-MANAGERS = ADMINS
-
-DATABASES = {
-    # Base de donnée du SI
-    'default': {
-        'ENGINE': 'django.db.backends.postgresql_psycopg2',
-        'NAME': 'coin',
-        'USER': 'coin',
-        'PASSWORD': '',
-        'HOST': '',  # Empty for localhost through domain sockets
-        'PORT': '',  # Empty for default
-    },
-}
-
-# Hosts/domain names that are valid for this site; required if DEBUG is False
-# See https://docs.djangoproject.com/en/1.5/ref/settings/#allowed-hosts
-ALLOWED_HOSTS = []
-
-# Local time zone for this installation. Choices can be found here:
-# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
-# although not all choices may be available on all operating systems.
-# In a Windows environment this must be set to your system time zone.
-TIME_ZONE = 'Europe/Paris'
-
-# Language code for this installation. All choices can be found here:
-# http://www.i18nguy.com/unicode/language-identifiers.html
-LANGUAGE_CODE = 'fr-fr'
-
-SITE_ID = 1
-
-# If you set this to False, Django will make some optimizations so as not
-# to load the internationalization machinery.
-USE_I18N = True
-
-# If you set this to False, Django will not format dates, numbers and
-# calendars according to the current locale.
-USE_L10N = True
-
-# If you set this to False, Django will not use timezone-aware datetimes.
-USE_TZ = True
-
-# Default URL for login and logout
-LOGIN_URL = '/members/login'
-LOGIN_REDIRECT_URL = '/members'
-LOGOUT_URL = '/members/logout'
-
-# Absolute filesystem path to the directory that will hold user-uploaded files.
-# Example: "/var/www/example.com/media/"
-MEDIA_ROOT = os.path.join(BASE_DIR, 'media/')
-
-# URL that handles the media served from MEDIA_ROOT. Make sure to use a
-# trailing slash.
-# Examples: "http://example.com/media/", "http://media.example.com/"
-MEDIA_URL = '/media/'
-
-# Absolute path to the directory static files should be collected to.
-# Don't put anything in this directory yourself; store your static files
-# in apps' "static/" subdirectories and in STATICFILES_DIRS.
-# Example: "/var/www/example.com/static/"
-STATIC_ROOT = os.path.join(BASE_DIR, 'static/')
-
-# URL prefix for static files.
-# Example: "http://example.com/static/", "http://static.example.com/"
-STATIC_URL = '/static/'
-
-# Additional locations of static files
-STATICFILES_DIRS = (
-    # Put strings here, like "/home/html/static" or "C:/www/django/static".
-    # Always use forward slashes, even on Windows.
-    # Don't forget to use absolute paths, not relative paths.
-)
-
-# List of finder classes that know how to find static files in
-# various locations.
-STATICFILES_FINDERS = (
-    'django.contrib.staticfiles.finders.FileSystemFinder',
-    'django.contrib.staticfiles.finders.AppDirectoriesFinder',
-    #'django.contrib.staticfiles.finders.DefaultStorageFinder',
-)
-
-# Location of private files. (Like invoices)
-# In production, this location should not be publicly accessible through
-# the web server
-PRIVATE_FILES_ROOT = os.path.join(BASE_DIR, 'smedia/')
-
-# Backend to use when sending private files to client
-# In production, must be sendfile.backends.xsendfile with Apache xsend file mod
-# Or failing xsendfile, use : sendfile.backends.simple
-# https://github.com/johnsensible/django-sendfile
-SENDFILE_BACKEND = 'sendfile.backends.development'
-
-# Make this unique, and don't share it with anybody.
-SECRET_KEY = '!qy_)gao6q)57#mz1s-d$5^+dp1nt=lk1d19&9bb3co37vn)!3'
-
-# List of callables that know how to import templates from various sources.
-TEMPLATE_LOADERS = (
-    'django.template.loaders.filesystem.Loader',
-    'django.template.loaders.app_directories.Loader',
-    #'django.template.loaders.eggs.Loader',
-)
-
-MIDDLEWARE_CLASSES = (
-    'django.middleware.common.CommonMiddleware',
-    'django.contrib.sessions.middleware.SessionMiddleware',
-    'django.middleware.csrf.CsrfViewMiddleware',
-    'django.contrib.auth.middleware.AuthenticationMiddleware',
-    'django.contrib.messages.middleware.MessageMiddleware',
-    # Uncomment the next line for simple clickjacking protection:
-    # 'django.middleware.clickjacking.XFrameOptionsMiddleware',
-)
-
-ROOT_URLCONF = 'coin.urls'
-
-# Python dotted path to the WSGI application used by Django's runserver.
-WSGI_APPLICATION = 'coin.wsgi.application'
-
-TEMPLATE_DIRS = (
-    # Only absolute paths, always forward slashes
-    os.path.join(PROJECT_PATH, 'templates/'),
-)
-
-INSTALLED_APPS = (
-    'django.contrib.auth',
-    'django.contrib.contenttypes',
-    'django.contrib.sessions',
-    'django.contrib.sites',
-    'ldapdb',  # LDAP as database backend
-    'django.contrib.messages',
-    'django.contrib.staticfiles',
-    # Uncomment the next line to enable the admin:
-    'django.contrib.admin',
-    # Uncomment the next line to enable admin documentation:
-    #'django.contrib.admindocs',
-    'polymorphic',
-    # 'south',
-    'autocomplete_light', #Automagic autocomplete foreingkey form component
-    'activelink', #Detect if a link match actual page
-    'coin',
-    'coin.members',
-    'coin.offers',
-    'coin.billing',
-    'coin.resources',
-    'coin.reverse_dns',
-    'coin.configuration',
-    'coin.vpn',
-    'coin.isp_database',
-    'simple_dsl',
-    'coin.dsl_ldap'
-)
-
-# A sample logging configuration. The only tangible logging
-# performed by this configuration is to send an email to
-# the site admins on every HTTP 500 error when DEBUG=False.
-# See http://docs.djangoproject.com/en/dev/topics/logging for
-# more details on how to customize your logging configuration.
-LOGGING = {
-    'version': 1,
-    'disable_existing_loggers': False,
-    'filters': {
-        'require_debug_false': {
-            '()': 'django.utils.log.RequireDebugFalse'
-        }
-    },
-    'handlers': {
-        'mail_admins': {
-            'level': 'ERROR',
-            'filters': ['require_debug_false'],
-            'class': 'django.utils.log.AdminEmailHandler'
-        }
-    },
-    'loggers': {
-        'django.request': {
-            'handlers': ['mail_admins'],
-            'level': 'ERROR',
-            'propagate': True,
-        },
-    }
-}
-
-TEMPLATE_CONTEXT_PROCESSORS = (
-    "django.contrib.auth.context_processors.auth",
-    "django.core.context_processors.debug",
-    "django.core.context_processors.i18n",
-    "django.core.context_processors.media",
-    "django.core.context_processors.static",
-    "django.core.context_processors.tz",
-    "django.core.context_processors.request",
-    "coin.isp_database.context_processors.branding",
-    "django.contrib.messages.context_processors.messages")
-
-AUTH_USER_MODEL = 'members.Member'
-
-AUTHENTICATION_BACKENDS = (
-    'django.contrib.auth.backends.ModelBackend',
-)
-
-TEST_RUNNER = 'django.test.runner.DiscoverRunner'
-
-GRAPHITE_SERVER = "http://localhost"
-
-# Configuration for outgoing emails
-#DEFAULT_FROM_EMAIL = "coin@example.com"
-#EMAIL_USE_TLS = True
-#EMAIL_HOST = "smtp.chezmoi.tld"
-
-# Do we use LDAP or not
-LDAP_ACTIVATE = False
-
-# Not setting them results in NameError
-LDAP_USER_BASE_DN = None
-VPN_CONF_BASE_DN = None
-DSL_CONF_BASE_DN = None
-
-# Membership configuration
-# Default cotisation in €, per year
-MEMBER_DEFAULT_COTISATION = 20
-
-# Link to a page with information on how to become a member or pay the
-# membership fee
-MEMBER_MEMBERSHIP_INFO_URL = '#'
-
-# Reset session if cookie older than 2h.
-SESSION_COOKIE_AGE = 7200
-
-# RSS/Atom feeds to display on dashboard
-# feed name (used in template), url, max entries to display
-# "isp" entry gets picked automatically in default index template
-FEEDS = (
-    ('ffdn', 'http://www.ffdn.org/fr/rss.xml', 3),
-#    ('isp', 'http://isp.example.com/feed/', 3),
-)
 # Surcharge les paramètres en utilisant le fichier settings_local.py
 try:
     from settings_local import *
 except ImportError:
     pass
+
+TEMPLATE_DIRS = EXTRA_TEMPLATE_DIRS + TEMPLATE_DIRS
+INSTALLED_APPS = INSTALLED_APPS + EXTRA_INSTALLED_APPS

+ 275 - 0
coin/settings_base.py

@@ -0,0 +1,275 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+import os
+import ldap
+
+# Django settings for coin project.
+
+# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
+BASE_DIR = os.path.dirname(os.path.dirname(__file__))
+
+PROJECT_PATH = os.path.abspath(os.path.dirname(__file__))
+DEBUG = TEMPLATE_DEBUG = True
+
+ADMINS = (
+    # ('Your Name', 'your_email@example.com'),
+)
+
+MANAGERS = ADMINS
+
+DATABASES = {
+    # Database hosted on vagant test box
+    'default': {
+        'ENGINE': 'django.db.backends.postgresql_psycopg2',
+        'NAME': 'coin',
+        'USER': 'coin',
+        'PASSWORD': 'coin',
+        'HOST': 'localhost',  # Empty for localhost through domain sockets
+        'PORT': '15432',  # Empty for default
+    },
+}
+
+# Hosts/domain names that are valid for this site; required if DEBUG is False
+# See https://docs.djangoproject.com/en/1.7/ref/settings/#allowed-hosts
+ALLOWED_HOSTS = []
+
+# Local time zone for this installation. Choices can be found here:
+# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
+# although not all choices may be available on all operating systems.
+# In a Windows environment this must be set to your system time zone.
+TIME_ZONE = 'Europe/Paris'
+
+# Language code for this installation. All choices can be found here:
+# http://www.i18nguy.com/unicode/language-identifiers.html
+LANGUAGE_CODE = 'fr-fr'
+
+SITE_ID = 1
+
+# If you set this to False, Django will make some optimizations so as not
+# to load the internationalization machinery.
+USE_I18N = True
+
+# If you set this to False, Django will not format dates, numbers and
+# calendars according to the current locale.
+USE_L10N = True
+
+# If you set this to False, Django will not use timezone-aware datetimes.
+USE_TZ = True
+
+# Default URL for login and logout
+LOGIN_URL = '/members/login'
+LOGIN_REDIRECT_URL = '/members'
+LOGOUT_URL = '/members/logout'
+
+# Absolute filesystem path to the directory that will hold user-uploaded files.
+# Example: "/var/www/example.com/media/"
+MEDIA_ROOT = os.path.join(BASE_DIR, 'media/')
+
+# URL that handles the media served from MEDIA_ROOT. Make sure to use a
+# trailing slash.
+# Examples: "http://example.com/media/", "http://media.example.com/"
+MEDIA_URL = '/media/'
+
+# Absolute path to the directory static files should be collected to.
+# Don't put anything in this directory yourself; store your static files
+# in apps' "static/" subdirectories and in STATICFILES_DIRS.
+# Example: "/var/www/example.com/static/"
+STATIC_ROOT = os.path.join(BASE_DIR, 'static/')
+
+# URL prefix for static files.
+# Example: "http://example.com/static/", "http://static.example.com/"
+STATIC_URL = '/static/'
+
+# Additional locations of static files
+STATICFILES_DIRS = (
+    # Put strings here, like "/home/html/static" or "C:/www/django/static".
+    # Always use forward slashes, even on Windows.
+    # Don't forget to use absolute paths, not relative paths.
+)
+
+# List of finder classes that know how to find static files in
+# various locations.
+STATICFILES_FINDERS = (
+    'django.contrib.staticfiles.finders.FileSystemFinder',
+    'django.contrib.staticfiles.finders.AppDirectoriesFinder',
+    #'django.contrib.staticfiles.finders.DefaultStorageFinder',
+)
+
+# Location of private files. (Like invoices)
+# In production, this location should not be publicly accessible through
+# the web server
+PRIVATE_FILES_ROOT = os.path.join(BASE_DIR, 'smedia/')
+
+# Backend to use when sending private files to client
+# In production, must be sendfile.backends.xsendfile with Apache xsend file mod
+# Or failing xsendfile, use : sendfile.backends.simple
+# https://github.com/johnsensible/django-sendfile
+SENDFILE_BACKEND = 'sendfile.backends.development'
+
+# Make this unique, and don't share it with anybody.
+SECRET_KEY = '!qy_)gao6q)57#mz1s-d$5^+dp1nt=lk1d19&9bb3co37vn)!3'
+
+# List of callables that know how to import templates from various sources.
+TEMPLATE_LOADERS = (
+    'django.template.loaders.filesystem.Loader',
+    'django.template.loaders.app_directories.Loader',
+    #'django.template.loaders.eggs.Loader',
+)
+
+MIDDLEWARE_CLASSES = (
+    'django.middleware.common.CommonMiddleware',
+    'django.contrib.sessions.middleware.SessionMiddleware',
+    'django.middleware.csrf.CsrfViewMiddleware',
+    'django.contrib.auth.middleware.AuthenticationMiddleware',
+    'django.contrib.messages.middleware.MessageMiddleware',
+    # Uncomment the next line for simple clickjacking protection:
+    # 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+)
+
+ROOT_URLCONF = 'coin.urls'
+
+# Python dotted path to the WSGI application used by Django's runserver.
+WSGI_APPLICATION = 'coin.wsgi.application'
+
+TEMPLATE_DIRS = (
+    # Only absolute paths, always forward slashes
+    os.path.join(PROJECT_PATH, 'templates/'),
+)
+
+EXTRA_TEMPLATE_DIRS = tuple()
+
+INSTALLED_APPS = (
+    'django.contrib.auth',
+    'django.contrib.contenttypes',
+    'django.contrib.sessions',
+    'django.contrib.sites',
+    'ldapdb',  # LDAP as database backend
+    'django.contrib.messages',
+    'django.contrib.staticfiles',
+    # Uncomment the next line to enable the admin:
+    'django.contrib.admin',
+    'netfields',
+    # Uncomment the next line to enable admin documentation:
+    #'django.contrib.admindocs',
+    'polymorphic',
+    # 'south',
+    'autocomplete_light', #Automagic autocomplete foreingkey form component
+    'activelink', #Detect if a link match actual page
+    'coin',
+    'coin.members',
+    'coin.offers',
+    'coin.billing',
+    'coin.resources',
+    'coin.reverse_dns',
+    'coin.configuration',
+    'coin.isp_database',
+)
+
+EXTRA_INSTALLED_APPS = tuple()
+
+# A sample logging configuration. The only tangible logging
+# performed by this configuration is to send an email to
+# the site admins on every HTTP 500 error when DEBUG=False.
+# See http://docs.djangoproject.com/en/dev/topics/logging for
+# more details on how to customize your logging configuration.
+LOGGING = {
+    'version': 1,
+    'disable_existing_loggers': False,
+    'formatters': {
+    },
+    'filters': {
+        'require_debug_false': {
+            '()': 'django.utils.log.RequireDebugFalse'
+        }
+    },
+    'handlers': {
+        'mail_admins': {
+            'level': 'ERROR',
+            'filters': ['require_debug_false'],
+            'class': 'django.utils.log.AdminEmailHandler'
+        },
+        'console': {
+            'class': 'logging.StreamHandler',
+        },
+    },
+    'loggers': {
+        'django.request': {
+            'handlers': ['mail_admins'],
+            'level': 'ERROR',
+            'propagate': True,
+        },
+        'django': {
+            'handlers': ['console'],
+            'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'),
+        },
+        "coin.billing": {
+            'handlers': ['console'],
+            'level': 'INFO',
+        }
+    }
+}
+
+TEMPLATE_CONTEXT_PROCESSORS = (
+    "django.contrib.auth.context_processors.auth",
+    "django.core.context_processors.debug",
+    "django.core.context_processors.i18n",
+    "django.core.context_processors.media",
+    "django.core.context_processors.static",
+    "django.core.context_processors.tz",
+    "django.core.context_processors.request",
+    "coin.isp_database.context_processors.branding",
+    "coin.context_processors.installed_apps",
+    "django.contrib.messages.context_processors.messages")
+
+AUTH_USER_MODEL = 'members.Member'
+
+AUTHENTICATION_BACKENDS = (
+    'django.contrib.auth.backends.ModelBackend',
+)
+
+TEST_RUNNER = 'django.test.runner.DiscoverRunner'
+
+GRAPHITE_SERVER = "http://localhost"
+
+# Configuration for outgoing emails
+#DEFAULT_FROM_EMAIL = "coin@example.com"
+#EMAIL_USE_TLS = True
+#EMAIL_HOST = "smtp.chezmoi.tld"
+
+# Do we use LDAP or not
+LDAP_ACTIVATE = False
+
+# Not setting them results in NameError
+LDAP_USER_BASE_DN = None
+VPN_CONF_BASE_DN = None
+DSL_CONF_BASE_DN = None
+
+# Membership configuration
+# Default cotisation in €, per year
+MEMBER_DEFAULT_COTISATION = 20
+
+# Link to a page with information on how to become a member or pay the
+# membership fee
+MEMBER_MEMBERSHIP_INFO_URL = ''
+
+# Pattern used to display a unique reference for any subscription
+# Helpful for bank wire transfer identification
+SUBSCRIPTION_REFERENCE = 'REF-{subscription.offer.reference}-{subscription.pk}'
+
+# Payment delay in days
+PAYMENT_DELAY = 30
+
+# Reset session if cookie older than 2h.
+SESSION_COOKIE_AGE = 7200
+
+# RSS/Atom feeds to display on dashboard
+# feed name (used in template), url, max entries to display
+# "isp" entry gets picked automatically in default index template
+FEEDS = (
+    ('ffdn', 'http://www.ffdn.org/fr/rss.xml', 3),
+#    ('isp', 'http://isp.example.com/feed/', 3),
+)
+
+# Member can edit their own data
+MEMBER_CAN_EDIT_PROFILE = False

+ 7 - 0
coin/settings_local.example-illyse.py

@@ -1,3 +1,10 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+EXTRA_INSTALLED_APPS = (
+    'vpn',
+)
+
 LDAP_ACTIVATE = True
 
 # Instance LDAP de développement:

+ 11 - 0
coin/settings_test.py

@@ -0,0 +1,11 @@
+from settings_base import *
+
+# settings for unit tests
+
+EXTRA_INSTALLED_APPS = (
+    'hardware_provisioning',
+    'vpn',
+)
+
+TEMPLATE_DIRS = EXTRA_TEMPLATE_DIRS + TEMPLATE_DIRS
+INSTALLED_APPS = INSTALLED_APPS + EXTRA_INSTALLED_APPS

+ 211 - 107
coin/static/css/local.css

@@ -1,41 +1,41 @@
 /* Titre général */
 
 header {
-	user-select: none;
-	/* Navigateurs */
-	-moz-user-select: none;
-	-webkit-user-select: none;
+    user-select: none;
+    /* Navigateurs */
+    -moz-user-select: none;
+    -webkit-user-select: none;
 }
 h1 {
-	font-size: 2.2em;
-	margin-bottom: 1em;
+    font-size: 2.2em;
+    margin-bottom: 1em;
 }
 h1:before {
-	content: "\\_o<";
-	color: #FF6600;
-	font-weight: normal;
-	font-family: monospace;
-	text-align: center;
-	font-size: 1.25em;
-	display: block;
-	float: left;
-	width: 25%;
+    content: "\\_o<";
+    color: #FF6600;
+    font-weight: normal;
+    font-family: monospace;
+    text-align: center;
+    font-size: 1.25em;
+    display: block;
+    float: left;
+    width: 25%;
 }
 
 h1:hover:before {
-	content: "\\_x<";
+    content: "\\_x<";
 }
 h1:hover:after {
-	position: absolute;
-	text-align: center;
-	z-index: -1;
-	right: 15%;
-	left: 25%;
+    position: absolute;
+    text-align: center;
+    z-index: -1;
+    right: 15%;
+    left: 25%;
 }
 
 h1 a, h1:after {
-	color: #0086A9;
-	font-weight: bold;
+    color: #0086A9;
+    font-weight: bold;
 }
 
 /* Barre de navigation */
@@ -48,74 +48,93 @@ h1 a, h1:after {
 
 /*
 nav#sidebox {
-	position: fixed;
-	z-index: 1;
+    position: fixed;
+    z-index: 1;
 }
 
 h3#nav {
-	background-color: #E8E8FF;
-	border: 1px solid #E0E0E0;
-	border-bottom-color: #0086A9;
-	padding-bottom: 0.25em;
-	text-indent: 0.25em;
-	margin-top: 0.25em;
-	padding-top: 0.1em;
-	margin-bottom: 0;
-	color: #FF6600;
+    background-color: #E8E8FF;
+    border: 1px solid #E0E0E0;
+    border-bottom-color: #0086A9;
+    padding-bottom: 0.25em;
+    text-indent: 0.25em;
+    margin-top: 0.25em;
+    padding-top: 0.1em;
+    margin-bottom: 0;
+    color: #FF6600;
 }*/
 /*
 .side-nav {
-	padding: 0;
-	padding-top: 0.75em;
-	background-color: #E8E8FF;
-	border: 1px solid #E0E0E0;
-	border-top: 0 none transparent;
-	margin-top: 0;
+    padding: 0;
+    padding-top: 0.75em;
+    background-color: #E8E8FF;
+    border: 1px solid #E0E0E0;
+    border-top: 0 none transparent;
+    margin-top: 0;
 }
 
 .side-nav a {
-	padding: 0 0.5em 0.2em 0.5em;
+    padding: 0 0.5em 0.2em 0.5em;
 }
 
 .side-nav a:hover,
 .side-nav li.active a,
 .side-nav li ul li a:hover {
-	background-color: #0086A9;
-	color: #FFFFFF !important;
-	border-radius: 5px;
+    background-color: #0086A9;
+    color: #FFFFFF !important;
+    border-radius: 5px;
 }
 
 .side-nav li ul {
-	list-style-type: disc;
-	padding-top: 0.25em;
+    list-style-type: disc;
+    padding-top: 0.25em;
 }
 
 .side-nav li ul li a:hover {
-	margin-right: 1em;
+    margin-right: 1em;
 }
 
 .side-nav li ul li a {
-	padding-left: 0.25em;
-	margin-left: -0.25em;
+    padding-left: 0.25em;
+    margin-left: -0.25em;
 }
 
 .side-nav li.active li a {
-	background-color: transparent;
-	color: #0086A9 !important;
+    background-color: transparent;
+    color: #0086A9 !important;
 }
 */
 /* Titre section principale */
 
 h2:before {
-	/*content: url(../img/coinitem.png);*/
-	margin-right: 0.25em;
+    /*content: url(../img/coinitem.png);*/
+    margin-right: 0.25em;
 }
 
 h2 {
-	color: #FF6600;
-	/*border-bottom: 2px solid #0086A9;*/
+    color: #FF6600;
+    /*border-bottom: 2px solid #0086A9;*/
+}
+
+
+/* panels */
+.panel {}
+.panel > h2,
+.panel > h3,
+.panel > h4,
+.panel > h5 {
+  border-bottom: 1px solid #d8d8d8;
+  margin-bottom: 1.25rem;
+  padding-bottom: 0.625rem;
+}
+.panel.callout > h2,
+.panel.callout > h3,
+.panel.callout > h4,
+.panel.callout > h5 {
+  border-color: #B5F0FF;
 }
 
+
 /* Tables */
 table.full-width {
     width:100%;
@@ -129,6 +148,61 @@ table.full-width {
 .hint {
 	font-size: 0.9em;
 	margin-top: -0.5em;
+
+table.no-border {
+    border: none;
+}
+table.no-background {}
+table.no-background,
+table.no-background thead,
+table.no-background tfoot,
+table.no-background tr {
+    background: transparent;
+}
+
+
+/* Specific table: Member personnal info */
+#personnal-info {
+    border-collapse: collapse;
+}
+#personnal-info td {
+    vertical-align: top;
+}
+#personnal-info tr:last-child td {
+    border-bottom: none;
+}
+#personnal-info tr td:first-child {
+    text-align: right;
+    color: #666;
+    font-weight: bold;
+}
+#personnal-info .email td {
+    /* email address can be reallllly long word */
+    overflow-wrap: break-word;
+    word-wrap: break-word;
+    word-break: break-all;
+}
+
+/* login page */
+#login-form {}
+#login-form table td {
+    vertical-align: middle;
+}
+#login-form table input {
+    margin-bottom: 0;
+}
+#login-form label {
+    font-size: 1.2em;
+}
+
+#password-reset-link {
+    margin-left: 1em;
+}
+
+
+/* New comers panel on login page */
+#newcomers {
+    margin-top: 3.9375rem;  /* h1 margin top + bottom + font-size * line-height =  0.2rem + 0.5rem + 2.3125rem * 1.4 */
 }
 
 /* Footer */
@@ -164,16 +238,14 @@ table.invoice-table td.total {
     width:100px;
 }
 
-#member_invoices td.unpaid {
+#member-invoices td.unpaid {
     color:red;
 }
-#member_invoices tr.total>td.right {
+#member-invoices tr.total>td.right {
     text-align:right;
 }
 
-#payment_howto p {
-    font-size:0.7rem;
-}
+#payment-howto {}
 
 /* Modifs pour les infos */
 td.center {
@@ -213,22 +285,22 @@ span.italic {
 }
 
 #graph h3 select {
-	display: inline;
-	background-color: transparent;
-	border: 0 none transparent;
-	font-family: inherit;
-	box-shadow: none;
-	font-size: 0.9em;
-	width: auto;
-	margin: 0;
-	padding: 0;
+    display: inline;
+    background-color: transparent;
+    border: 0 none transparent;
+    font-family: inherit;
+    box-shadow: none;
+    font-size: 0.9em;
+    width: auto;
+    margin: 0;
+    padding: 0;
 }
 #graph h3 select option {
-	font-size: 0.6em;
+    font-size: 0.6em;
 }
 
 a.cfglink {
-	white-space: nowrap;
+    white-space: nowrap;
 }
 
 tr.inactive {
@@ -241,44 +313,52 @@ tr.inactive {
    content: "Les champs marqués d'un ✎ sont éditables.";
 }
 .flatform .legend {
-	clear: both;
+    clear: both;
 }
 
 .flatfield label {
-	background-color: #0086A9;
-	color: #F0F0F0;
-	font-size: 0.9em;
-	padding: 0.2em 0.5em;
-	white-space: nowrap;
+    background-color: #0086A9;
+    color: #F0F0F0;
+    font-size: 0.9em;
+    padding: 0.2em 0.5em;
+    white-space: nowrap;
 }
 .flatfield label:before {
-	content: "✎ ";
-	color: #E9E9E9;
+    content: "✎ ";
+    color: #E9E9E9;
 }
 
 .flatfield input {
-	margin-bottom: 0;
-	border: 1px solid #E9E9E9;
-	background-color: transparent;
-	text-overflow: ellipsis;
-	padding-left: 1em;
-	box-shadow: none;
-	font-size: 1.1em;
-	color: #222222;
-	width: 90%;
+    margin-bottom: 0;
+    border: 1px solid #E9E9E9;
+    background-color: transparent;
+    text-overflow: ellipsis;
+    padding-left: 1em;
+    box-shadow: none;
+    font-size: 1.1em;
+    color: #222222;
+    width: 90%;
 }
 .flatfield input::-moz-placeholder {
-	font-style: italic;
+    font-style: italic;
 }
 .flatfield input:-ms-input-placeholder {
-	font-style: italic;
+    font-style: italic;
 }
 .flatfield input::-webkit-input-placeholder {
-	font-style: italic;
+    font-style: italic;
 }
 .flatfield input:focus {
-	background-color: #FFFFFF;
-	border: 2px solid #C0C0C0;
+    background-color: #FFFFFF;
+    border: 2px solid #C0C0C0;
+}
+
+form .helptext {
+	position: relative;
+	top: -1em;
+	margin: 0em 1em 0em 1em;
+	font-style: italic;
+	font-size: small;
 }
 
 /* Feeds */
@@ -294,35 +374,59 @@ tr.inactive {
 }
 
 .legend .button, .formcontrol .button {
-	padding: 0.25em 0.5em;
-	border-radius:5px;
-	font-size: 0.9em;
+    padding: 0.25em 0.5em;
+    border-radius:5px;
+    font-size: 0.9em;
 }
 
 .nogap ul {
-	margin-bottom: 0;
+    margin-bottom: 0;
 }
 .nogap ul li {
-	list-style-type: none;
+    list-style-type: none;
 }
 .errored input {
-	border-color: #FF0000;
-	box-shadow: #FF7777 0 0 5px;
+    border-color: #FF0000;
+    box-shadow: #FF7777 0 0 5px;
 }
 .formcontrol {
-	text-align: right;
-	margin-right: 1em;
+    text-align: right;
+    margin-right: 1em;
+}
+
+.message {
+    padding: 0.5em;
+    text-align: center;
+    margin: 1em 0;
 }
 
 .message.success {
-	padding: 0.5em;
 	color: #FFFFFF;
-	text-align: center;
 	background-color: #20BA44;
-	/*background-color: #00A986;*/
-	border: 1px solid #E0E0E0;
-	margin: -1.5em 1em 1em 1em;
+}
+
+.message.warning {
+    color: #620
+    background-color: #FFAE00;
+    font-style: normal;
+    border-radius: 0;
+}
+
+.eat-up {
+	margin-top: -1.5em;
 }
 .message.success:before {
     content: "✔ ";
 }
+
+.nowrap {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+/* List filters links */
+
+.list-filter {
+	text-align: right;
+}

+ 2 - 1
coin/templates/base.html

@@ -9,6 +9,7 @@
     <link rel="stylesheet" href="{% static "css/font-awesome.min.css"%}" />
     <link rel="stylesheet" href="{% static "css/local.css" %}" />
     <link rel="stylesheet" href="{% static "css/offcanvas.css" %}">
+    {% block extra_css %}{% endblock %}
     <script src="{% static "js/vendor/modernizr.js" %}"></script>
     <link rel="icon" type="image/png" href="{% static "img/coinitem.png" %}"/>
     <link rel="icon" type="image/x-icon" href="{% static "img/favicon.ico" %}" />
@@ -89,7 +90,7 @@
 <script src="{% static "js/foundation.min.js" %}"></script>
 <script src="{% static "js/foundation/foundation.offcanvas.js" %}"></script>
 <script src="{% static "js/utils.js" %}"></script>
-{% block js %}{% endblock js %}
+{% block extra_js %}{% endblock extra_js %}
 
 <script>
   $(document).foundation();

+ 6 - 2
coin/templates/menu_items.html

@@ -2,10 +2,14 @@
 <li class="{% ifactive 'home' %}active{% endifactive %}"><a href="{% url 'home' %}"><i class="fa fa-home fa-fw"></i> Tableau de bord</a></li>
 <li class="{% ifactive 'members:detail' %}active{% endifactive %}"><a href="{% url 'members:detail' %}"><i class="fa fa-user fa-fw"></i> Mes informations</a></li>
 <li class="{% ifactive 'members:subscriptions' %}active{% endifactive %}"><a href="{% url 'members:subscriptions' %}"><i class="fa fa-cog fa-fw"></i> Mes abonnements</a></li>
-<li class="{% ifactive 'members:invoices' %}active{% endifactive %}"><a href="{% url 'members:invoices' %}"><i class="fa fa-eur fa-fw"></i> Mes factures</a></li>
+<li class="{% ifactive 'members:invoices' %}active{% endifactive %}"><a href="{% url 'members:invoices' %}"><i class="fa fa-eur fa-fw"></i> Factures &amp; paiements</a></li>
+{% if 'hardware_provisioning' in INSTALLED_APPS %}
+<li class="{% ifactive 'hardware_provisioning:loan-list' %}active{% endifactive %}"><a href="{% url 'hardware_provisioning:loan-list' %}"><i
+            class="fa fa-exchange fa-fw"></i> Mon matériel</a></li>
+{% endif %}
 <li class="{% ifactive 'members:contact' %}active{% endifactive %}"><a href="{% url 'members:contact' %}"><i class="fa fa-life-ring fa-fw"></i> Contact / Support</a></li>
 <li class="divider"></li>
 {% if user.is_staff %}<li><a href="{% url 'admin:index' %}"><i class="fa fa-cogs fa-fw"></i> Administration</a></li>{% endif %}
 <li class="{% ifactive 'members:password_change' %}active{% endifactive %}"><a href="{% url 'members:password_change' %}"><i class="fa fa-key fa-fw"></i> Modifier mon mot de passe</a></li>
 <li class="divider"></li>
-<li class="{% ifactive '' %}active{% endifactive %}"><a href="{% url 'members:logout' %}"><i class="fa fa-power-off fa-fw"></i> Dépannexion</a></li>
+<li class="{% ifactive '' %}active{% endifactive %}"><a href="{% url 'members:logout' %}"><i class="fa fa-power-off fa-fw"></i> Déconnexion</a></li>

+ 16 - 6
coin/urls.py

@@ -1,13 +1,14 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
+from django.apps import apps
 from django.conf import settings
 from django.conf.urls import patterns, include, url
 from django.conf.urls.static import static
 from django.contrib.staticfiles.urls import staticfiles_urlpatterns
 
 from coin import views
-
+import coin.apps
 
 import autocomplete_light
 autocomplete_light.autodiscover()
@@ -17,6 +18,17 @@ admin.autodiscover()
 
 from coin.isp_database.views import isp_json
 
+
+def apps_urlpatterns():
+    """ Yields url lists ready to be appended to urlpatterns list
+    """
+    for app_config in apps.get_app_configs():
+        if isinstance(app_config, coin.apps.AppURLs):
+            for prefix, pats in app_config.exported_urlpatterns:
+                yield url(
+                    r'^{}/'.format(prefix),
+                    include(pats, namespace=prefix))
+
 urlpatterns = patterns(
     '',
     url(r'^$', 'coin.members.views.index', name='home'),
@@ -24,11 +36,7 @@ urlpatterns = patterns(
     url(r'^isp.json$', isp_json),
     url(r'^members/', include('coin.members.urls', namespace='members')),
     url(r'^billing/', include('coin.billing.urls', namespace='billing')),
-    url(r'^subscription/', include('coin.offers.urls', namespace='subscription')),
-    url(r'^vpn/', include('coin.vpn.urls', namespace='vpn')),
-    url(r'^dsl/', include('coin.dsl_ldap.urls', namespace='dsl_ldap')),
-
-    url(r'^admin/', include(admin.site.urls)),
+    url(r'^subscription/', include('coin.offers.urls', namespace='subscription')),    url(r'^admin/', include(admin.site.urls)),
 
     # url(r'^admin/doc/', include('django.contrib.admindocs.urls')),
 
@@ -41,3 +49,5 @@ urlpatterns = patterns(
 urlpatterns += staticfiles_urlpatterns()
 urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
 
+# Pluggable apps URLs
+urlpatterns += list(apps_urlpatterns())

+ 38 - 2
coin/utils.py

@@ -11,6 +11,7 @@ import re
 import sys
 from datetime import date, timedelta
 from contextlib import contextmanager
+from functools import wraps
 
 from django.utils import translation
 from django.core.mail import EmailMultiAlternatives
@@ -30,6 +31,13 @@ re_chat_url = re.compile(r'(?P<protocol>\w+://)(?P<server>[\w\.]+)/(?P<channel>.
 def str_or_none(obj):
     return str(obj) if obj else None
 
+def rstrip_str(s, suffix):
+    """Return a copy of the string [s] with the string [suffix] removed from
+    the end (if [s] ends with [suffix], otherwise return s)."""
+    if s.endswith(suffix):
+        return s[:-len(suffix)]
+    else:
+        return s
 
 def ldap_hash(password):
     """Hash a password for use with LDAP.  If the password is already hashed,
@@ -47,9 +55,11 @@ def ldap_hash(password):
         return password
 
 
-def send_templated_email(to, subject_template, body_template, context={}, attachements=[]):
+def send_templated_email(to, subject_template, body_template, context={}, attachements=[], **kwargs):
     """
     Send a multialternative email based on html and optional txt template.
+
+    :param **kwargs: extra-args pased as-is to EmailMultiAlternatives()
     """
 
     # Ensure arrays when needed
@@ -84,7 +94,7 @@ def send_templated_email(to, subject_template, body_template, context={}, attach
         text_content = html2text.html2text(html_content)
 
     # make multipart email default : text, alternative : html
-    msg = EmailMultiAlternatives(subject=subject, body=text_content, to=to)
+    msg = EmailMultiAlternatives(subject=subject, body=text_content, to=to, **kwargs)
     msg.attach_alternative(html_content, "text/html")
 
     # Set attachements
@@ -158,6 +168,32 @@ def generate_weak_password(length):
     """
     return "".join(["%c" % random.randrange(0x61, 0x7B) for i in range(length)])
 
+def disable_for_loaddata(signal_handler):
+    """Decorator for post_save events that disables them when loading
+    data from fixtures."""
+    @wraps(signal_handler)
+    def wrapper(*args, **kwargs):
+        if kwargs['raw']:
+            return
+        signal_handler(*args, **kwargs)
+    return wrapper
+
+
+def postgresql_regexp(regexp):
+    """ Make a PCRE regexp PostgreSQL compatible (kinda)
+
+    PostgreSQL forbids using capture-group names, this function removes them.
+    :param regexp: a PCRE regexp or pattern
+    :return a PostgreSQL regexp
+    """
+    try:
+        original_pattern = regexp.pattern
+    except AttributeError:
+        original_pattern = regexp
+
+    return re.sub(
+        r'\?P<.*?>', '', original_pattern)
+
 
 if __name__ == '__main__':
     # ldap_hash expects an unicode string

+ 8 - 0
coin/validation.py

@@ -2,6 +2,9 @@
 from __future__ import unicode_literals
 
 from django.core.exceptions import ValidationError
+from django.core.validators import RegexValidator
+
+from .utils import re_chat_url
 
 
 def validate_v4(address):
@@ -12,3 +15,8 @@ def validate_v4(address):
 def validate_v6(address):
     if address.version != 6:
         raise ValidationError('{} is not an IPv6 address'.format(address))
+
+
+chatroom_url_validator = RegexValidator(
+    regex=re_chat_url,
+    message="Enter a value of the form  <proto>://<server>/<channel>")

+ 35 - 0
contrib/ansible/coin-customizations/django_local_settings.py.j2

@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+DEBUG = TEMPLATE_DEBUG = False
+
+ADMINS = (
+#      ('admin1', 'admin1@example.org'),
+)
+
+LDAP_ACTIVATE = False
+
+DATABASES = {
+     # Base de donnée du SI
+     'default': {
+         'ENGINE': 'django.db.backends.postgresql_psycopg2',
+         'NAME': '{{ db_name }}',
+         'USER': '{{ db_user }}',
+         'PASSWORD': '{{ db_password }}',
+     },
+}
+
+ALLOWED_HOSTS = ['{{ public_fqdn }}', ]
+SECRET_KEY = 'changeme'
+
+STATIC_ROOT = '{{ www_static_assets_directory }}'
+
+# Configuration for outgoing emails
+#DEFAULT_FROM_EMAIL = "faimaison-si@legeox.net"
+#EMAIL_USE_TLS = True
+#EMAIL_HOST = "localhost"
+
+MEMBER_DEFAULT_COTISATION = 16
+
+FEEDS = (
+          #('isp', 'https://faimaison.net/feeds/all.atom.xml', 3),
+          ('ffdn', 'http://www.ffdn.org/fr/rss.xml', 3)
+        )

+ 20 - 0
contrib/ansible/coin-customizations/lighttpd-coin.conf.j2

@@ -0,0 +1,20 @@
+$HTTP["host"] == "{{ public_fqdn }}" {
+
+  debug.log-request-handling = "enable"
+  accesslog.filename = "{{ lighttpd_log_file }}"
+
+  $HTTP["url"] !~ "^/(media/|static/|favicon.ico$)" {
+
+    proxy.server = ( "" =>
+                      ( 
+                        ( "host" => "{{ gunicorn_binding_address }}",
+                          "port" => {{ gunicorn_port }}
+                        )
+                      )
+                   )
+  }
+
+  alias.url = (
+    "/static/" => "{{ www_static_assets_directory }}/",
+  )
+}

+ 6 - 0
contrib/ansible/coin-customizations/supervisor-coin.conf.j2

@@ -0,0 +1,6 @@
+[program:coin-si-gunicorn]
+directory = {{ working_directory }}
+user = {{ user_name }}
+command = {{ virtualenv_directory }}/bin/python {{ virtualenv_directory }}/bin/gunicorn wsgi:application --user={{ user_name }} --group={{ user_name }} --bind {{ gunicorn_binding_address }}:{{ gunicorn_port }} --log-level=debug --log-file={{ user_logs_dir }}/guni-ilb.log
+stdout_logfile = {{ user_logs_dir }}/gunicorn-std.log
+stderr_logfile = {{ user_logs_dir }}/gunicorn-err.log

+ 32 - 0
contrib/ansible/coin-customizations/wsgi.py.j2

@@ -0,0 +1,32 @@
+"""
+WSGI config for project.
+
+This module contains the WSGI application used by Django's development server
+and any production WSGI deployments. It should expose a module-level variable
+named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover
+this application via the ``WSGI_APPLICATION`` setting.
+
+Usually you will have the standard Django WSGI application here, but it also
+might make sense to replace the whole Django WSGI application with a custom one
+that later delegates to the Django one. For example, you could introduce WSGI
+middleware here, or combine a Django application with an application of another
+framework.
+
+"""
+import os
+
+# We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks
+# if running multiple sites in the same mod_wsgi process. To fix this, use
+# mod_wsgi daemon mode with each site in its own daemon process, or use
+# os.environ["DJANGO_SETTINGS_MODULE"] = "settings"
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "coin.settings")
+
+# This application object is used by any WSGI server configured to use this
+# file. This includes Django's development server, if the WSGI_APPLICATION
+# setting points here.
+from django.core.wsgi import get_wsgi_application
+application = get_wsgi_application()
+
+# Apply WSGI middleware here.
+# from helloworld.wsgi import HelloWorldApplication
+# application = HelloWorldApplication(application)

+ 127 - 0
contrib/ansible/si.yml

@@ -0,0 +1,127 @@
+- hosts: si-demo-server
+  sudo: yes
+  vars:
+    # public host name users will use to access Coin
+    public_fqdn: "coin.example.org"
+    # directory where configuration templates are stored
+    custom_coin_files_directory: "coin-customizations"
+    # unix user who will run app server
+    user_name: "coin"
+    # PostgreSQL database name
+    db_name: "illyse_coin"
+    # PostgreSQL user name
+    db_user: "illyse_coin"
+    # PostgreSQL password
+    db_password: "illyse_coin_change_me"
+    # PostgreSQL address
+    db_host: "localhost"
+    # PostgreSQL port
+    db_port: "5432"
+    # Gunicorn server binding address (address on which the process will listen)
+    gunicorn_binding_address: "127.0.0.1"
+    # Gunicorn server listening port
+    gunicorn_port: "3036"
+    # Path of the directory where statics assets will be stored (and served by web server)
+    www_static_assets_directory: "/var/www/coin/static"
+    # Enable or disable configuration of lighttpd as reverse proxy
+    lighttpd_enabled: "true"
+    user_home: "/home/{{user_name}}"
+    user_logs_dir: "{{ user_home }}/logs"
+    working_directory: "{{user_home}}/coin"
+    virtualenv_directory: "{{user_home}}/venv"
+    supervisor_tasks_conf_directory: "/etc/supervisor/conf.d/"
+    lighttpd_log_file: "/var/log/lighttpd/si-coin-django.log"
+    www_user: "www-data"
+  tasks:
+    # Setup: prerequisites
+    # note: we put postgresql as needed, but Coin can work with other backends supported by Coin.
+    # as an example, SQLite is known to work
+    - name: prerequisites are installed
+      apt: pkg=git-core,python-dev,python-pip,libldap2-dev,libpq-dev,libsasl2-dev,python-virtualenv,postgresql,postgresql-server-dev-9.1,python-psycopg2,supervisor
+           state=installed
+
+    # Setup: PostgreSQL
+    - name: create postgres user "{{ db_user }}"
+      postgresql_user: name={{ db_user }} password={{ db_password }} 
+      sudo_user: postgres
+    - name: create postgres db "{{ db_name }}"
+      postgresql_db: name={{ db_name }} encoding="UTF-8" lc_collate='fr_FR.UTF-8' lc_ctype='fr_FR.UTF-8' template=template0 
+      sudo_user: postgres
+    - name: local socket authentication via password is allowed
+      lineinfile: dest=/etc/postgresql/9.1/main/pg_hba.conf line="local   {{ db_name }}     {{ db_user }}                             password"
+                  insertafter="^local\s+all\s+postgres\s+peer"
+      notify: restart_postgres
+    - meta: flush_handlers
+    - name: grant all to {{ db_user }} on {{ db_name }}
+      postgresql_privs: db={{ db_name }} privs=ALL type=database role={{ db_user }}
+      sudo_user: postgres
+    # Setup: Coin
+    - name: create user {{ user_name }}
+      user: name={{ user_name }} state=present shell=/bin/false createhome=yes
+    - name: git, get Coin code
+      git: repo=git://git.illyse.org/coin.git dest={{ working_directory }} version=HEAD accept_hostkey=yes
+      sudo_user: "{{ user_name }}"
+    - name: install Coin python requirements
+      pip: requirements={{ working_directory }}/requirements.txt virtualenv={{ virtualenv_directory }}
+      sudo_user: "{{ user_name }}"
+    - name: copy custom Coin settings file
+      template: src={{ custom_coin_files_directory }}/django_local_settings.py.j2 dest={{ working_directory }}/coin/settings_local.py
+                owner={{ user_name }} group={{ user_name }}
+    - name: django migrations are applied
+      command: "{{ virtualenv_directory }}/bin/python {{ working_directory }}/manage.py migrate"
+      sudo_user: "{{ user_name }}"
+    - name: gunicorn is installed
+      pip: name=gunicorn virtualenv={{ virtualenv_directory }}
+      sudo_user: "{{ user_name }}"
+    - name: log directory is present in user's home
+      file: path={{ user_logs_dir }} state=directory owner={{ user_name }} group={{ user_name }}
+    - name: wsgi.py is present
+      template: src={{ custom_coin_files_directory }}/wsgi.py.j2 dest={{ working_directory }}/wsgi.py
+                owner={{ user_name }} group={{ user_name }}
+    - name: supervisord config file is present
+      template: src={{ custom_coin_files_directory }}/supervisor-coin.conf.j2 dest={{ supervisor_tasks_conf_directory }}/supervisor-coin.conf
+      notify:
+        - reread_supervisor_config
+        - restart_coin_supervisor_task
+    - name: static assets directory exists
+      file: path={{ www_static_assets_directory }} state=directory
+            owner={{ www_user }} group={{ user_name }} mode=0774
+    - name: django collect static assets files
+      command: "{{ virtualenv_directory }}/bin/python {{ working_directory }}/manage.py collectstatic --noinput"
+      sudo_user: "{{ user_name }}"
+
+    # Configure lighttpd as reverse proxy (only if lighttpd_enabled var is "true")
+    - name: lighttpd is installed
+      apt: pkg=lighttpd state=installed
+      when: lighttpd_enabled == "true"
+    - name: lighttpd mod proxy is enabled
+      file: src=/etc/lighttpd/conf-available/10-proxy.conf
+            dest=/etc/lighttpd/conf-enabled/10-proxy.conf
+            state=link
+      notify: restart_lighttpd
+      when: lighttpd_enabled == "true"
+    - name: lighttpd mod accesslog is enabled
+      file: src=/etc/lighttpd/conf-available/10-accesslog.conf
+            dest=/etc/lighttpd/conf-enabled/10-accesslog.conf
+            state=link
+      notify: restart_lighttpd
+      when: lighttpd_enabled == "true"
+    - name: lighttpd config file for {{ public_fqdn }} is present
+      template: src={{ custom_coin_files_directory }}/lighttpd-coin.conf.j2 dest=/etc/lighttpd/conf-available/20-coin.conf
+      notify: restart_lighttpd
+      when: lighttpd_enabled == "true"
+    - name: lighttpd config file for {{ public_fqdn }} is enabled
+      file: src=/etc/lighttpd/conf-available/20-coin.conf
+            dest=/etc/lighttpd/conf-enabled/20-coin.conf
+            state=link
+      notify: restart_lighttpd
+      when: lighttpd_enabled == "true"
+  handlers:
+    - name: restart_postgres
+      service: name=postgresql state=reloaded
+    - name: reread_supervisor_config
+      supervisorctl: name=coin-si-gunicorn state=present
+    - name: restart_coin_supervisor_task
+      supervisorctl: name=coin-si-gunicorn state=restarted
+    - name: restart_lighttpd
+      service: name=lighttpd state=restarted

+ 154 - 0
doc/user/permissions.md

@@ -0,0 +1,154 @@
+Permissions (sur l'interface d'administration)
+==============================================
+
+Par défaut, un membre n'a pas accès à l'interface d'administration.
+
+Organisation
+------------
+
+Les permissions d'un membre se changent dans sa fiche. Seuls les
+super-utilisateurs peuvent modifier les permissions.
+
+### Statut équipe
+
+Il permet d'autoriser un membre à se connecter à l'interface
+d'administration. Un bouton *« Administration »* apparaîtra alors dans son
+menu. En l'absence d'appartenance à un [groupe](#groupes) ou
+du [statut super-utilisateur](#statut-super-utilisateur), le statut équipe
+donne accès à une interface d'administration vide.
+
+### Statut super-utilisateur
+
+Un membre avec le *statut super-utilisateur* peut lire et modifier toutes les
+informations gérées par coin. C'est typiquement un statut à réserver aux
+membres du bureau.
+
+### Groupes
+
+Les *groupes* permettent simplement de réunir les membres par niveau
+d'accès. Un *groupe* inclut donc un ou plusieurs *membres* et se voit attribuer
+une ou plusieurs [permissions](#permissions).
+
+Un membre peut appartenir à plusieurs groupes.
+
+### Permissions
+
+Les permissions permettent de choisir précisément à quelles données peuvent
+accéder les membres d'un [groupe](#groupe).
+
+#### Permissions par opération
+
+On peut gérer les permissions d'accès pour chaque opération réalisable dans
+coin. Une opération est la combinaison d'un *type d'opération* et d'un *type de
+donnée*.
+
+- Les **types d'opérations** sont : *création*, *suppression* *modification*.
+- Les **types de données** principaux sont : membre, abonnement, offre… La
+liste complète est affichée aux super-utilisateurs sur la page d'accueil de
+l'administration.
+
+
+**NB**: Le droit de *lecture* est accordé avec le droit de *modification*. Le droit
+de *lecture seule* n'existe donc pas.
+
+Les permissions sur les *abonnements*, les *offres* et les *membres* sont de plus
+restreintes par les [permissions fines](#permissions-fines-par-offre).
+
+#### Permissions fines (par offre)
+
+Ce sont des permissions qui permettent de n'autoriser l'accès qu'à une partie
+des données en fonction de leur contenu. Ces permissions ne se substituent pas
+aux [permissions par opération](#permissions-par-operation), elles en limitent
+le champ d'application.
+
+Les *types de données* dont l'accès est limité par les *permissions fines* sont :
+
+- offres
+- abonnements
+- membre
+
+Les *permissions fines* permettent ce genre de logique :
+
+- Les membres du groupe « Admins VPN » n'ont accès qu'à ce qui concerne les
+  abonnés et abonnements VPN.
+- Les membres du groupe « Wifi Machin » n'ont accès qu'à ce qui concerne les
+  abonnements wifi du quartier machin
+- etc…
+
+Le critère sur lequel une donnée est accessible ou non est donc l'offre
+souscrite.
+
+
+Exemples
+--------
+
+## Exemple pour un groupe gérant le matériel et les emprunts
+
+1. Créer un **groupe** « Matos » (dans la section *Auth*) avec toutes les
+   permissions mentionnant l'application « hardware_provisioning ».
+2. Pour chaque *membre* qui va gérer le matos, aller sur sa fiche, et dans la
+   rubrique *Permissions* :
+
+  - activer son *Statut équipe*
+  - l'ajouter au groupe  « Matos »
+
+**NB:** Quand un membre de notre groupe « Matos » déclare un nouvel emprunt, il
+devra tapper au moins 3 caractères du nom du membre qui emprunte, de cette façon
+un utilisateur qui n'est pas super-utilisateur n'a pas accès facilement à la
+liste de tous les membres.
+
+
+
+## Exemple pour un groupe gérant les abonnements ADSL
+
+1. **Pour chaque offre ADSL, créer une Row Level Permission** (dans la section
+   *Members*) correspondante (c'est pénible mais on est obligé de faire une
+   permission par *offre*). Par exemple, si on a deux offres ADSL :
+
+   | Champ          | Valeur                            	|
+   |----------------|---------------------------------------|
+   | Nom          	| Permission ADSL Marque blanche    	|
+   | Content Type 	| abonnement                        	|
+   | Nom de code  	| perm-adsl-marque-blanche          	|
+   | Offre        	| Marque blanche FDN - 32 € / mois  	|
+
+ et
+
+   | Champ          | Valeur                            	            |
+   |----------------|---------------------------------------------------|
+   | Nom          	| Permission ADSL Marque blanche (préférentiel)    	|
+   | Content Type 	| abonnement                        	            |
+   | Nom de code  	| perm-adsl-marque-blanche-pref        	            |
+   | Offre        	| Marque blanche FDN - 32 € / mois  	            |
+
+2. **Créer un Groupe** (dans la section *Auth*) nommé « ADSL » avec les
+   permissions suivantes :
+  - `membres | membre | Can add membre` pour que les *membres* du groupe
+    puissent créer de nouvelles fiches membre
+  - `membres | membre | Can change membre` pour qu'ils puissent voir et éditer
+    les infos des membres, ils n'auront accès qu'aux membres qui ont souscrit à
+    un abonnement ADSL
+  - `offers | abonnement | Can add abonnement` pour qu'ils puissent une
+    souscription d'abonnement
+  - `offers | abonnement | Can change abonnement` pour qu'ils puissent modifier
+    une souscription abonnement
+  - `offers | abonnement | Can delete abonnement` si l'on veut qu'ils puissent
+    supprimer des abonnements (à réfléchir, peut être souhaitable ou non)
+  - `offers | abonnement | perm-adsl-marque-blanche` pour qu'ils puissent avoir
+    accès aux membres qui ont souscrit à l'offre correspondante (permission
+    qu'on vient de créer au 1.)
+  - `offers | abonnement | perm-adsl-marque-blanche-pref` (idem)
+
+3. **Pour chaque membre** qui va gérer l'ADSL, aller sur sa fiche et dans la
+   rubrique *Permissions* :
+  - lui ajouter le *Statut équipe* (afin qu'il puisse se connecter à l'interface d'admin)
+  - l'ajouter au groupe « ADSL »
+
+Les membres du groupe peuvent maintenant ajouter / modifier des membres et
+des abonnements.
+
+**Attention :** pour respecter la vie privée, les membres du groupe n'ont accès
+qu'aux membres qui ont un abonnement ADSL. Donc s'ils veulent enregistrer un
+nouveau membre il faut renseigner son abonnement *au moment de la création de
+la fiche membre* (en bas du formulaire membre) ; sinon la fiche du nouveau
+membre va être créée mais sera invisible (erreur 404, sauf pour le bureau).

+ 1 - 0
hardware_provisioning/__init__.py

@@ -0,0 +1 @@
+default_app_config = 'hardware_provisioning.app.HardwareProvisioningConfig'

+ 192 - 0
hardware_provisioning/admin.py

@@ -0,0 +1,192 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import unicode_literals
+
+
+from django.contrib import admin
+from django.contrib.auth import get_user_model
+from django.forms import ModelChoiceField
+from django.utils import timezone
+import autocomplete_light
+
+from .models import ItemType, Item, Loan, Storage
+import coin.members.admin
+
+
+User = get_user_model()
+
+admin.site.register(ItemType)
+
+
+class OwnerFilter(admin.SimpleListFilter):
+    title = "Propriétaire"
+    parameter_name = 'owner'
+
+    def lookups(self, request, model_admin):
+        owners = [
+            (i.pk, i) for i in User.objects.filter(items__isnull=False)]
+
+        return [(None, "L'association")] + owners
+
+    def queryset(self, request, queryset):
+        if self.value():
+            return queryset.filter(owner__pk=self.value())
+        else:
+            return queryset
+
+
+class AvailabilityFilter(admin.SimpleListFilter):
+    title = "Disponibilité"
+    parameter_name = 'availability'
+
+    def lookups(self, request, model_admin):
+        return [
+            ('available', 'Disponible'),
+            ('borrowed', 'Emprunté'),
+        ]
+
+    def queryset(self, request, queryset):
+        if self.value() == 'available':
+            return queryset.available()
+        elif self.value() == 'borrowed':
+            return queryset.borrowed()
+        else:
+            return queryset
+
+
+@admin.register(Item)
+class ItemAdmin(admin.ModelAdmin):
+    list_display = (
+        'designation', 'type', 'mac_address', 'serial', 'owner',
+        'buy_date', 'deployed', 'is_available')
+    list_filter = (
+        AvailabilityFilter, 'type__name', 'storage',
+        'buy_date', OwnerFilter)
+    search_fields = (
+        'designation', 'mac_address', 'serial',
+        'owner__email', 'owner__nickname',
+        'owner__first_name', 'owner__last_name')
+    save_as = True
+    actions = ['give_back']
+
+    form = autocomplete_light.modelform_factory(Loan, fields='__all__')
+
+    def give_back(self, request, queryset):
+        for item in queryset.filter(loans__loan_date_end=None):
+            item.give_back()
+    give_back.short_description = 'Rendre le matériel'
+
+
+class StatusFilter(admin.SimpleListFilter):
+    title = 'Statut'
+    parameter_name = 'status'
+
+    def lookups(self, request, model_admin):
+        return [
+            ('all', 'Tout'),
+            (None, 'En cours'),
+            ('finished', 'Passés'),
+        ]
+
+    def choices(self, cl):
+        for lookup, title in self.lookup_choices:
+            yield {
+                'selected': self.value() == lookup,
+                'query_string': cl.get_query_string({
+                    self.parameter_name: lookup,
+                }, []),
+                'display': title,
+            }
+
+    def queryset(self, request, queryset):
+        v = self.value()
+        if v in (None, 'running'):
+            return queryset.running()
+        elif v == 'finished':
+            return queryset.finished()
+        else:
+            return queryset
+
+
+class BorrowerFilter(admin.SimpleListFilter):
+    title = 'Adhérent emprunteur'
+    parameter_name = 'user'
+
+    def lookups(self, request, model_admin):
+        users = set()
+        for loan in model_admin.get_queryset(request):
+            users.add((loan.user.pk, loan.user))
+        return users
+
+    def queryset(self, request, queryset):
+        if self.value():
+            return queryset.filter(user=self.value())
+        else:
+            return queryset
+
+
+class ItemChoiceField(ModelChoiceField):
+    # On surcharge cette méthode pour afficher mac et n° de série dans le menu
+    # déroulant de sélection d'un objet dans la création d'un prêt.
+    def label_from_instance(self, obj):
+        return obj.designation + ' ' + obj.get_mac_and_serial()
+
+@admin.register(Loan)
+class LoanAdmin(admin.ModelAdmin):
+    list_display = ('item', 'get_mac_and_serial', 'user', 'loan_date', 'loan_date_end')
+    list_filter = (StatusFilter, BorrowerFilter, 'item__designation')
+    search_fields = (
+        'item__designation',
+        'user__nickname', 'user__username',
+        'user__first_name', 'user__last_name', )
+    actions = ['end_loan']
+
+    def end_loan(self, request, queryset):
+        queryset.filter(loan_date_end=None).update(
+            loan_date_end=datetime.now())
+    end_loan.short_description = 'Mettre fin au prêt'
+
+    form = autocomplete_light.modelform_factory(Loan, fields='__all__')
+
+    def formfield_for_foreignkey(self, db_field, request, **kwargs):
+        if db_field.name == 'item':
+            kwargs['queryset'] = Item.objects.all()
+            return ItemChoiceField(**kwargs)
+        else:
+            return super(LoanAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)
+
+
+@admin.register(Storage)
+class StorageAdmin(admin.ModelAdmin):
+    list_display = ('name', 'truncated_notes', 'items_count')
+
+    def truncated_notes(self, obj):
+        if len(obj.notes) > 50:
+            return '{}…'.format(obj.notes[:50])
+        else:
+            return obj.notes
+    truncated_notes.short_description = 'notes'
+
+class LoanInline(admin.TabularInline):
+    model = Loan
+    extra = 0
+    exclude = ('notes',)
+    readonly_fields = ('item', 'get_mac_and_serial', 'loan_date', 'loan_date_end', 'is_running')
+
+    show_change_link = True
+
+    def get_queryset(self, request):
+        qs = super(LoanInline, self).get_queryset(request)
+        return qs.order_by('-loan_date_end')
+
+    def has_add_permission(self, request, obj=None):
+        return False
+
+    def has_delete_permission(self, request, obj=None):
+        return False
+
+class MemberAdmin(coin.members.admin.MemberAdmin):
+    inlines = coin.members.admin.MemberAdmin.inlines + [LoanInline]
+
+admin.site.unregister(coin.members.admin.Member)
+admin.site.register(coin.members.admin.Member, MemberAdmin)

+ 11 - 0
hardware_provisioning/app.py

@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import unicode_literals
+from django.apps import AppConfig
+import coin.apps
+
+
+class HardwareProvisioningConfig(AppConfig, coin.apps.AppURLs):
+    name = 'hardware_provisioning'
+    verbose_name = 'Prêt de matériel'
+    exported_urlpatterns = [('hardware_provisioning', 'hardware_provisioning.urls')]

+ 39 - 0
hardware_provisioning/fields.py

@@ -0,0 +1,39 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import unicode_literals
+import re
+
+from django.utils.translation import ugettext_lazy as _
+from django.forms import fields
+from django.db import models
+
+MAC_RE = r'^([0-9a-fA-F]{2}([:-]?|$)){6}$'
+mac_re = re.compile(MAC_RE)
+
+
+class MACAddressFormField(fields.RegexField):
+    default_error_messages = {
+        'invalid': _(u'Enter a valid MAC address.'),
+    }
+
+    def __init__(self, *args, **kwargs):
+        super(MACAddressFormField, self).__init__(mac_re, *args, **kwargs)
+
+
+class MACAddressField(models.Field):
+    empty_strings_allowed = False
+
+    def __init__(self, *args, **kwargs):
+        kwargs['max_length'] = 17
+        super(MACAddressField, self).__init__(*args, **kwargs)
+
+    def get_internal_type(self):
+        return "CharField"
+
+    def formfield(self, **kwargs):
+        defaults = {'form_class': MACAddressFormField}
+        defaults.update(kwargs)
+        return super(MACAddressField, self).formfield(**defaults)
+
+    # def get_db_prep_value(self, value, *args, **kwargs):
+    #     return filter(lambda ch: ch not in ':-', value).upper()

+ 54 - 0
hardware_provisioning/forms.py

@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+
+from django.core.exceptions import ValidationError
+from django.contrib.auth import get_user_model
+from django.db.models import Q
+from django import forms
+
+from .models import Storage
+from .validators import validate_future_date
+
+User = get_user_model()
+
+
+class LoanDeclareForm(forms.Form):
+    loan_date_end = forms.DateField(
+        label='Date de retour prévue',
+        required=False,
+        validators=[validate_future_date],
+        input_formats=['%d/%m/%Y'],
+        help_text='laisser vide si non planifié',
+        widget=forms.TextInput(
+            attrs={'type': 'date', 'placeholder': 'JJ/MM/AAAA'}))
+
+
+class LoanReturnForm(forms.Form):
+    storage = forms.ModelChoiceField(
+        label='Dans quel lieu de stockage ai-je remis le matériel ?',
+        required=False,
+        queryset=Storage.objects.all(), empty_label='Je ne sais pas')
+
+
+class LoanTransferForm(forms.Form):
+    target_user = forms.CharField(
+        max_length=100,
+        label='Adhérent',
+        help_text='email, pseudonyme ou numéro de l\'adhérent',
+    )
+
+    def clean_target_user(self):
+        value = self.cleaned_data['target_user']
+        result = User.objects.filter(
+            Q(email__iexact=value)
+            | Q(pk__iexact=value)
+            | Q(nickname__iexact=value)
+            | Q(username__iexact=value)
+        )
+        if result.count() > 1:
+            raise ValidationError(
+                "La recherche retourne plus d'un adhérent")
+        elif result.count() < 1:
+            raise ValidationError(
+                "Aucun adhérent ne correspond à cette recherche")
+
+        return result.first()

+ 70 - 0
hardware_provisioning/migrations/0001_initial.py

@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+from django.conf import settings
+import hardware_provisioning.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Item',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('designation', models.CharField(max_length=100, verbose_name='d\xe9signation')),
+                ('mac_address', hardware_provisioning.fields.MACAddressField(max_length=17, null=True, verbose_name='addresse MAC', blank=True)),
+                ('buy_date', models.DateTimeField(verbose_name='date d\u2019achat')),
+                ('comment', models.TextField(null=True, verbose_name='commentaire', blank=True)),
+            ],
+            options={
+                'verbose_name': 'objet',
+            },
+            bases=(models.Model,),
+        ),
+        migrations.CreateModel(
+            name='ItemType',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('name', models.CharField(max_length=100, verbose_name='nom')),
+            ],
+            options={
+                'verbose_name': 'type d\u2019objet',
+                'verbose_name_plural': 'types d\u2019objet',
+            },
+            bases=(models.Model,),
+        ),
+        migrations.CreateModel(
+            name='Loan',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('loan_date', models.DateTimeField(verbose_name='date de pr\xeat')),
+                ('loan_date_end', models.DateTimeField(null=True, verbose_name='date de fin de pr\xeat', blank=True)),
+                ('location', models.CharField(max_length=100, null=True, verbose_name='emplacement', blank=True)),
+                ('item', models.ForeignKey(related_name='loans', verbose_name='objet', to='hardware_provisioning.Item')),
+                ('user', models.ForeignKey(related_name='loans', verbose_name='membre', to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'verbose_name': 'pr\xeat d\u2019objet',
+                'verbose_name_plural': 'pr\xeats d\u2019objets',
+            },
+            bases=(models.Model,),
+        ),
+        migrations.AddField(
+            model_name='item',
+            name='type',
+            field=models.ForeignKey(related_name='items', verbose_name='type de mat\xe9riel', to='hardware_provisioning.ItemType'),
+            preserve_default=True,
+        ),
+        migrations.AddField(
+            model_name='item',
+            name='user_in_charge',
+            field=models.ForeignKey(related_name='items', verbose_name='membre responsable', to=settings.AUTH_USER_MODEL),
+            preserve_default=True,
+        ),
+    ]

+ 32 - 0
hardware_provisioning/migrations/0002_auto_20150625_2313.py

@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('hardware_provisioning', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='item',
+            name='buy_date',
+            field=models.DateField(verbose_name='date d\u2019achat'),
+            preserve_default=True,
+        ),
+        migrations.AlterField(
+            model_name='loan',
+            name='loan_date',
+            field=models.DateField(verbose_name='date de pr\xeat'),
+            preserve_default=True,
+        ),
+        migrations.AlterField(
+            model_name='loan',
+            name='loan_date_end',
+            field=models.DateField(null=True, verbose_name='date de fin de pr\xeat', blank=True),
+            preserve_default=True,
+        ),
+    ]

+ 15 - 0
hardware_provisioning/migrations/0003_auto_20160405_1812.py

@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('hardware_provisioning', '0002_auto_20150625_2313'),
+    ]
+
+    operations = [
+        migrations.RenameField('item', 'user_in_charge', 'owner')
+    ]

+ 21 - 0
hardware_provisioning/migrations/0004_auto_20160405_1816.py

@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+from django.conf import settings
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('hardware_provisioning', '0003_auto_20160405_1812'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='item',
+            name='owner',
+            field=models.ForeignKey(related_name='items', blank=True, to=settings.AUTH_USER_MODEL, help_text="dans le cas de mat\xe9riel n'appartenant pas \xe0 l'association", null=True, verbose_name='Propri\xe9taire'),
+            preserve_default=True,
+        ),
+    ]

+ 27 - 0
hardware_provisioning/migrations/0005_auto_20160405_1841.py

@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+import hardware_provisioning.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('hardware_provisioning', '0004_auto_20160405_1816'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='item',
+            name='serial',
+            field=models.CharField(help_text='ou toute autre r\xe9f\xe9rence unique)', max_length=250, verbose_name='N\xb0 de s\xe9rie', blank=True),
+            preserve_default=True,
+        ),
+        migrations.AlterField(
+            model_name='item',
+            name='mac_address',
+            field=hardware_provisioning.fields.MACAddressField(help_text='pr\xe9f\xe9rable au n\xb0 de s\xe9rie si possible', max_length=17, null=True, verbose_name='addresse MAC', blank=True),
+            preserve_default=True,
+        ),
+    ]

+ 27 - 0
hardware_provisioning/migrations/0006_storage.py

@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('hardware_provisioning', '0005_auto_20160405_1841'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Storage',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('name', models.CharField(max_length=100, verbose_name='nom')),
+                ('notes', models.TextField(help_text='Lisible par tous les adh\xe9rents', blank=True)),
+            ],
+            options={
+                'verbose_name': 'lieu de stockage',
+                'verbose_name_plural': 'lieux de stockage',
+            },
+            bases=(models.Model,),
+        ),
+    ]

+ 20 - 0
hardware_provisioning/migrations/0007_item_storage.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('hardware_provisioning', '0006_storage'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='item',
+            name='storage',
+            field=models.ForeignKey(related_name='items', blank=True, to='hardware_provisioning.Storage', help_text='Laisser vide si inconnu', null=True, verbose_name='Lieu de stockage'),
+            preserve_default=True,
+        ),
+    ]

+ 15 - 0
hardware_provisioning/migrations/0008_auto_20160405_2234.py

@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('hardware_provisioning', '0007_item_storage'),
+    ]
+
+    operations = [
+        migrations.RenameField('loan', 'location', 'notes')
+    ]

+ 20 - 0
hardware_provisioning/migrations/0009_auto_20160405_2236.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('hardware_provisioning', '0008_auto_20160405_2234'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='loan',
+            name='notes',
+            field=models.TextField(null=True, verbose_name='emplacement', blank=True),
+            preserve_default=True,
+        ),
+    ]

+ 20 - 0
hardware_provisioning/migrations/0010_auto_20160405_2237.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('hardware_provisioning', '0009_auto_20160405_2236'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='loan',
+            name='notes',
+            field=models.TextField(null=True, blank=True),
+            preserve_default=True,
+        ),
+    ]

+ 0 - 0
hardware_provisioning/migrations/0011_auto_20161028_2009.py


Some files were not shown because too many files changed in this diff