#43 Hardware loaning app

Fusionné
jocelyn a fusionné 37 commits à partir de jocelyn/hardware_provisioning vers FFDN/master il y a 8 ans
38 fichiers modifiés avec 1094 ajouts et 16 suppressions
  1. 33 0
      EXTENDING.md
  2. 7 0
      coin/context_processors.py
  3. 2 2
      coin/members/templates/members/index.html
  4. 1 1
      coin/members/templates/members/registration/password_reset_form.html
  5. 1 0
      coin/settings.py
  6. 33 8
      coin/static/css/local.css
  7. 2 1
      coin/templates/base.html
  8. 4 0
      coin/templates/menu_items.html
  9. 2 1
      coin/urls.py
  10. 1 0
      hardware_provisioning/__init__.py
  11. 146 0
      hardware_provisioning/admin.py
  12. 11 0
      hardware_provisioning/app.py
  13. 39 0
      hardware_provisioning/fields.py
  14. 54 0
      hardware_provisioning/forms.py
  15. 70 0
      hardware_provisioning/migrations/0001_initial.py
  16. 32 0
      hardware_provisioning/migrations/0002_auto_20150625_2313.py
  17. 15 0
      hardware_provisioning/migrations/0003_auto_20160405_1812.py
  18. 21 0
      hardware_provisioning/migrations/0004_auto_20160405_1816.py
  19. 27 0
      hardware_provisioning/migrations/0005_auto_20160405_1841.py
  20. 27 0
      hardware_provisioning/migrations/0006_storage.py
  21. 20 0
      hardware_provisioning/migrations/0007_item_storage.py
  22. 15 0
      hardware_provisioning/migrations/0008_auto_20160405_2234.py
  23. 20 0
      hardware_provisioning/migrations/0009_auto_20160405_2236.py
  24. 20 0
      hardware_provisioning/migrations/0010_auto_20160405_2237.py
  25. 0 0
      hardware_provisioning/migrations/__init__.py
  26. 144 0
      hardware_provisioning/models.py
  27. 24 0
      hardware_provisioning/static/hardware_provisioning/css/local.css
  28. 5 0
      hardware_provisioning/templates/hardware_provisioning/base.html
  29. 14 0
      hardware_provisioning/templates/hardware_provisioning/item_borrow.html
  30. 38 0
      hardware_provisioning/templates/hardware_provisioning/item_list.html
  31. 67 0
      hardware_provisioning/templates/hardware_provisioning/list.html
  32. 13 0
      hardware_provisioning/templates/hardware_provisioning/return.html
  33. 18 0
      hardware_provisioning/templates/hardware_provisioning/transfer.html
  34. 3 0
      hardware_provisioning/tests.py
  35. 15 0
      hardware_provisioning/urls.py
  36. 11 0
      hardware_provisioning/validators.py
  37. 136 0
      hardware_provisioning/views.py
  38. 3 3
      vpn/templates/vpn/vpn.html

+ 33 - 0
EXTENDING.md

@@ -169,3 +169,36 @@ Optionaly, you can customize which URLs are plugged and to which prefix via the
         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.

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

+ 2 - 2
coin/members/templates/members/index.html

@@ -49,7 +49,7 @@
 {% endblock %}
 
 
-{% block js %}
+{% block extra_js %}
     {{ block.super }}
     <script>
     $(function() {
@@ -71,4 +71,4 @@
         });
     });
     </script>
-{% endblock js %}
+{% endblock extra_js %}

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

+ 1 - 0
coin/settings.py

@@ -205,6 +205,7 @@ TEMPLATE_CONTEXT_PROCESSORS = (
     "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'

+ 33 - 8
coin/static/css/local.css

@@ -195,7 +195,6 @@ table.no-background tr {
     margin-top: 3.9375rem;  /* h1 margin top + bottom + font-size * line-height =  0.2rem + 0.5rem + 2.3125rem * 1.4 */
 }
 
-
 /* Footer */
 
 #footer {
@@ -344,6 +343,14 @@ tr.inactive {
     border: 2px solid #C0C0C0;
 }
 
+form .helptext {
+	position: relative;
+	top: -1em;
+	margin: 0em 1em 0em 1em;
+	font-style: italic;
+	font-size: small;
+}
+
 /* Feeds */
 .feed {
     font-size:80%;
@@ -377,14 +384,26 @@ tr.inactive {
     margin-right: 1em;
 }
 
-.message.success {
+.message {
     padding: 0.5em;
-    color: #FFFFFF;
     text-align: center;
-    background-color: #20BA44;
-    /*background-color: #00A986;*/
-    border: 1px solid #E0E0E0;
-    margin: -1.5em 1em 1em 1em;
+    margin: 1em 0;
+}
+
+.message.success {
+	color: #FFFFFF;
+	background-color: #20BA44;
+}
+
+.message.warning {
+    color: #620
+    background-color: #FFAE00;
+    font-style: normal;
+    border-radius: 0;
+}
+
+.eat-up {
+	margin-top: -1.5em;
 }
 .message.success:before {
     content: "✔ ";
@@ -394,4 +413,10 @@ tr.inactive {
     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();

+ 4 - 0
coin/templates/menu_items.html

@@ -3,6 +3,10 @@
 <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> 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 %}

+ 2 - 1
coin/urls.py

@@ -36,7 +36,8 @@ 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'^hardware-provisioning/', include('hardware_provisioning.urls',
+        namespace='hardware_provisioning')),
 
     url(r'^admin/', include(admin.site.urls)),
 

+ 1 - 0
hardware_provisioning/__init__.py

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

+ 146 - 0
hardware_provisioning/admin.py

@@ -0,0 +1,146 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import unicode_literals
+
+from datetime import date
+
+from django.contrib import admin
+from django.contrib.auth import get_user_model
+from .models import ItemType, Item, Loan, Storage
+
+
+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', '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')
+    actions = ['give_back']
+
+    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
+
+
+@admin.register(Loan)
+class LoanAdmin(admin.ModelAdmin):
+    list_display = ('item', '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=date.today())
+    end_loan.short_description = 'Mettre fin au prêt'
+
+
+@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'

+ 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/__init__.py


+ 144 - 0
hardware_provisioning/models.py

@@ -0,0 +1,144 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import unicode_literals
+from datetime import date
+from django.db import models
+from django.conf import settings
+from .fields import MACAddressField
+
+
+class ItemType(models.Model):
+    name = models.CharField(max_length=100, verbose_name='nom')
+
+    def __unicode__(self):
+        return self.name
+
+    class Meta:
+        verbose_name = 'type d’objet'
+        verbose_name_plural = 'types d’objet'
+
+
+class ItemQuerySet(models.QuerySet):
+    def _get_borrowed_pks(self):
+        return Loan.objects.running().values_list('item', flat=True)
+
+    def available(self):
+        return self.exclude(pk__in=self._get_borrowed_pks())
+
+    def borrowed(self):
+        return self.filter(pk__in=self._get_borrowed_pks())
+
+
+class Item(models.Model):
+    type = models.ForeignKey(ItemType, verbose_name='type de matériel',
+                             related_name='items')
+    designation = models.CharField(max_length=100, verbose_name='désignation')
+    storage = models.ForeignKey(
+        'Storage', related_name='items',
+        verbose_name='Lieu de stockage',
+        null=True, blank=True,
+        help_text='Laisser vide si inconnu')
+    mac_address = MACAddressField(
+        verbose_name='addresse MAC',
+        blank=True, null=True,
+        help_text="préférable au n° de série si possible")
+    serial = models.CharField(
+        verbose_name='N° de série',
+        max_length=250, blank=True,
+        help_text='ou toute autre référence unique)')
+    buy_date = models.DateField(verbose_name='date d’achat')
+    owner = models.ForeignKey(
+        settings.AUTH_USER_MODEL,
+        verbose_name='Propriétaire',
+        related_name='items',
+        null=True, blank=True,
+        help_text="dans le cas de matériel n'appartenant pas à l'association")
+    comment = models.TextField(verbose_name='commentaire', blank=True,
+                               null=True)
+
+    objects = ItemQuerySet().as_manager()
+
+    def __unicode__(self):
+        return self.designation
+
+    def get_current_loan(self):
+        """
+        Returns the current Loan for this Item, if exists, or None.
+        """
+        try:
+            return self.loans.get(loan_date_end__isnull=True)
+        except Loan.DoesNotExist:
+            return None
+
+    def is_available(self):
+        """
+        Returns the status of the Item. If a Loan without an end date exists,
+        returns False (else True).
+        """
+        if self.loans.running().exists():
+            return False
+        return True
+    is_available.boolean = True
+    is_available.short_description = 'disponible'
+
+    class Meta:
+        verbose_name = 'objet'
+
+    def give_back(self, storage=None):
+        self.storage = storage
+        self.save()
+        self.loans.running().update(
+            loan_date_end=date.today())
+
+
+class LoanQuerySet(models.QuerySet):
+    running_filter = (
+        models.Q(loan_date_end__gt=date.today()) |
+        models.Q(loan_date_end__isnull=True))
+
+    def running(self):
+        return self.filter(self.running_filter)
+
+    def finished(self):
+        return self.exclude(self.running_filter)
+
+
+class Loan(models.Model):
+    item = models.ForeignKey(Item, verbose_name='objet', related_name='loans')
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name='membre',
+                             related_name='loans')
+    loan_date = models.DateField(verbose_name='date de prêt')
+    loan_date_end = models.DateField(verbose_name='date de fin de prêt',
+                                     null=True, blank=True)
+    notes = models.TextField(null=True, blank=True)
+
+    def __unicode__(self):
+        return 'prêt de {item} à {user}'.format(
+            item=self.item, user=self.user)
+
+    def user_can_close(self, user):
+        return (not self.item.is_available()) and (self.user == user)
+
+    class Meta:
+        verbose_name = 'prêt d’objet'
+        verbose_name_plural = 'prêts d’objets'
+
+    objects = LoanQuerySet().as_manager()
+
+
+class Storage(models.Model):
+    name = models.CharField(max_length=100, verbose_name='nom')
+    notes = models.TextField(
+        blank=True,
+        help_text='Lisible par tous les adhérents')
+
+    def __unicode__(self):
+        return self.name
+
+    def items_count(self):
+        return self.items.count()
+    items_count.short_description = 'Nb. items stockés'
+
+    class Meta:
+        verbose_name = 'lieu de stockage'
+        verbose_name_plural = 'lieux de stockage'

+ 24 - 0
hardware_provisioning/static/hardware_provisioning/css/local.css

@@ -0,0 +1,24 @@
+/* Listing table with action button */
+
+.pre-table-action {
+	float: right;
+}
+
+table tr.placeholder td {
+	text-align: center;
+	font-style: italic;
+}
+
+table .actions {
+	text-align: center;
+}
+
+table .actions .button {
+	width: 100%;
+}
+
+/* List filters links */
+
+.list-filter {
+	text-align: right;
+}

+ 5 - 0
hardware_provisioning/templates/hardware_provisioning/base.html

@@ -0,0 +1,5 @@
+{% extends "base.html" %}
+
+{% load static %}
+
+{% block extra_css %}<link rel="stylesheet" href="{% static "hardware_provisioning/css/local.css" %}">{% endblock %}

+ 14 - 0
hardware_provisioning/templates/hardware_provisioning/item_borrow.html

@@ -0,0 +1,14 @@
+{% extends 'hardware_provisioning/base.html' %}
+
+{% block content %}
+
+<p>Je déclare emprunter le matériel <strong>{{item }} ({{ item.type }}</strong>) à compter de ce jour.</p>
+
+
+<form method='post'>{% csrf_token %}
+{{ form.as_p }}
+    <input class="success button" type="submit"
+           value="Oui oui, c'est bien ça."/>
+</form>
+
+{% endblock %}

+ 38 - 0
hardware_provisioning/templates/hardware_provisioning/item_list.html

@@ -0,0 +1,38 @@
+{% extends "hardware_provisioning/base.html" %}
+
+{% block content %}
+<h2>J'emprunte à l'association…</h2>
+
+<table>
+    <thead>
+        <tr>
+            <th>Type de matériel</th>
+            <th>Désignation</th>
+            <th>Dépôt</th>
+            <th>Addr. MAC</th>
+            <th>Num. de série</th>
+            <th>Actions</th>
+        </tr>
+    </thead>
+
+    <tbody>
+       {% for i in items %}
+        <tr>
+            <td>{{ i.type }}</td>
+            <td>{{ i.designation }}</td>
+            <td>{{ i.storage|default:'inconnu' }}</td>
+            <td>{{ i.mac_address|default:'n/a' }}</td>
+            <td>{{ i.serial|default:'n/a' }}</td>
+            <td class="actions">
+                <div class="button-group">
+                  <a href="{% url 'hardware_provisioning:item-borrow' pk=i.pk %}" class="small button">
+                     <i class="fa fa-exchange"></i>&nbsp;Emprunter
+                 </a>
+                </div>
+            </td>
+        </tr>
+        {% endfor %}
+    </tbody>
+
+</table>
+{% endblock %}

+ 67 - 0
hardware_provisioning/templates/hardware_provisioning/list.html

@@ -0,0 +1,67 @@
+{% extends "hardware_provisioning/base.html" %}
+
+{% block content %}
+{% if view  == 'old' %}
+<h2>L'association m'a prêté…</h2>
+{% else %}
+<h2>L'association me prête…</h2>
+{% endif %}
+{% for message in messages %}
+<div class="message {{ message.tags }}">{{ message }}</div>
+{% endfor %}
+
+<p>
+    <a href="{% url 'hardware_provisioning:item-list' %}"
+       class="button pre-table-action success">
+        <i class="fa fa-plus"></i> Déclarer un emprunt
+    </a>
+</p>
+
+<table id="member_loans" class="full-width">
+    <thead>
+        <tr>
+            <th>Type de matériel</th>
+            <th>Matériel prêté</th>
+            <th>Date de prêt</th>
+            {% if view == 'old' %}<th>Date retour</th>{% endif %}
+            <th>Addr. MAC</th>
+            <th>Num. de série</th>
+            {% if view != 'old' %}<th>Actions</th>{% endif %}
+        </tr>
+    </thead>
+    <tbody>
+        {% for loan in loans %}
+        <tr>
+            <td>{{ loan.item.type }}</td>
+            <td>{{ loan.item }}</a></td>
+            <td>{{ loan.loan_date }}</td>
+            {% if view == 'old' %}<td>{{ loan.loan_date_end }}</td>{% endif %}
+            <td>{{ loan.item.mac_address|default:"n/a" }}</td>
+            <td>{{ loan.item.serial|default:"n/a" }}</td>
+            {% if view != 'old' %}
+            <td class="actions">
+              <div class="button-group">
+                  <a href="{% url 'hardware_provisioning:loan-return' pk=loan.pk %}" class="small button">
+                     <i class="fa fa-times"></i>&nbsp;Rendre
+                 </a>
+                <a href="{% url 'hardware_provisioning:loan-transfer' pk=loan.pk %}" class="small button">
+                   <i class="fa fa-exchange"></i> Transférer
+                </a>
+              </div>
+            </td>
+            {% endif %}
+        </tr>
+        {% empty %}
+        <tr class="placeholder"><td colspan="6">… rien du tout !</td></tr>
+        {% endfor %}
+    </tbody>
+</table>
+
+<p class="list-filter">
+    {% if view == 'old' %}
+    <a href="./">Afficher les prêts en cours</a>
+    {% else %}
+    <a href="?old">Afficher l'historique de prêt</a>
+    {% endif %}
+</p>
+{% endblock %}

+ 13 - 0
hardware_provisioning/templates/hardware_provisioning/return.html

@@ -0,0 +1,13 @@
+{% extends "hardware_provisioning/base.html" %}
+
+{% block content %}
+<h2>Retour de matériel</h2>
+<p>
+    J'ai rendu le matériel <strong>{{loan.item }} ({{ loan.item.type }}</strong>) a l'association ce jour.
+</p>
+<form method='post'>{% csrf_token %}
+  {{ form.as_p }}
+  <input class="success button" type="submit"
+         value="Oui oui, c'est bien ça."/>
+</form>
+{% endblock %}

+ 18 - 0
hardware_provisioning/templates/hardware_provisioning/transfer.html

@@ -0,0 +1,18 @@
+{% extends "hardware_provisioning/base.html" %}
+
+{% block content %}
+<h2>Transfert de matériel</h2>
+<p>
+    J'ai passé aujourd'hui le matériel <strong>{{  loan.item }}
+    ({{ loan.item.type }}</strong>) a un autre adhérent :
+</p>
+<form method="post">{% csrf_token %}
+  <p>
+    <input id="id_target_user"
+           name="target_user"
+           type="text" placeholder="{{ form.target_user.help_text }}"/>
+    {{ form.target_user.errors }}
+  <input class="success button" type="submit"
+         value="Oui oui, c'est bien ça."/>
+</form>
+{% endblock %}

+ 3 - 0
hardware_provisioning/tests.py

@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.

+ 15 - 0
hardware_provisioning/urls.py

@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import unicode_literals
+from django.conf.urls import url
+from . import views
+
+
+urlpatterns = [
+    url(r'^$', views.loan_list, name='loan-list'),
+    url(r'^items/list$', views.item_list, name='item-list'),
+    url(r'^items/(?P<pk>[0-9]+)/borrow$', views.item_borrow, name='item-borrow'),
+    url(r'^(?P<pk>[0-9]+)/return$', views.loan_return, name='loan-return'),
+    url(r'^(?P<pk>[0-9]+)/transfer$', views.loan_transfer, name='loan-transfer'),
+    url(r'^(?P<pk>[0-9]+)$', views.loan_detail, name='loan-detail'),
+]

+ 11 - 0
hardware_provisioning/validators.py

@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+
+import datetime
+
+from django.core.exceptions import ValidationError
+
+
+def validate_future_date(value):
+    if value <= datetime.date.today():
+        raise ValidationError(
+            'La date de retour doit être dans le futur')

+ 136 - 0
hardware_provisioning/views.py

@@ -0,0 +1,136 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import unicode_literals
+
+import datetime
+
+from django.shortcuts import get_object_or_404, render, redirect
+from django.contrib.auth.decorators import login_required
+from django.contrib import messages
+from django.http import HttpResponseForbidden
+from django.core.urlresolvers import reverse
+
+from .forms import LoanDeclareForm, LoanTransferForm, LoanReturnForm
+from .models import Item, Loan
+
+
+@login_required
+def item_list(request):
+    items = Item.objects.all().order_by('storage', 'type', 'designation')
+
+    # FIXME: suboptimal
+    items = [i for i in items.filter() if i.is_available()]
+    return render(request, 'hardware_provisioning/item_list.html', {
+        'items': items,
+    })
+
+
+@login_required
+def item_borrow(request, pk):
+    item = get_object_or_404(Item, pk=pk)
+
+    if not item.is_available():
+        return HttpResponseForbidden('Item non disponible')
+
+    if request.method == 'POST':
+        form = LoanDeclareForm(request.POST)
+        if form.is_valid():
+            loan = Loan.objects.create(
+                item=item,
+                loan_date=datetime.date.today(),
+                loan_date_end=form.cleaned_data['loan_date_end'],
+                user=request.user,
+            )
+            messages.success(
+                request, "Emprunt de {} ({}) enregistré".format(
+                    item.designation, item.type))
+            if not loan.loan_date_end:
+                messages.warning(
+                    request,
+                    "N'oubliez pas de notifier le retour de l'objet le temps venu")
+            return redirect(reverse('hardware_provisioning:loan-list'))
+    else:
+        form = LoanDeclareForm()
+
+    return render(request, 'hardware_provisioning/item_borrow.html', {
+        'item': item,
+        'form': form,
+    })
+
+
+@login_required
+def loan_return(request, pk):
+    loan = get_object_or_404(Loan, pk=pk)
+
+    if not loan.user_can_close(request.user):
+        return HttpResponseForbidden('Non autorisé')
+
+    if request.method == 'POST':
+        form = LoanReturnForm(request.POST)
+        if form.is_valid():
+            messages.success(
+                request,
+                'Le matériel {} a été marqué comme rendu'.format(
+                    loan.item))
+            loan.item.give_back(form.cleaned_data['storage'])
+            return redirect(reverse('hardware_provisioning:loan-list'))
+    else:
+        form = LoanReturnForm()
+
+    return render(request, 'hardware_provisioning/return.html', {
+        'loan': loan,
+        'form': form,
+    })
+
+
+@login_required
+def loan_transfer(request, pk):
+    """ Transfer something loaned to another member
+    """
+    old_loan = get_object_or_404(Loan, pk=pk)
+
+    if not old_loan.user_can_close(request.user):
+        return HttpResponseForbidden()
+
+    if request.method == 'POST':
+        form = LoanTransferForm(request.POST)
+        if form.is_valid():
+            old_loan.item.give_back()
+            Loan.objects.create(
+                user=form.cleaned_data['target_user'],
+                loan_date=datetime.date.today(),
+                item=old_loan.item)
+            messages.success(
+                request,
+                "Le matériel {} a été transféré à l'adhérent \"{}\"".format(
+                    old_loan.item,
+                    form.data['target_user']))
+            return redirect(reverse('hardware_provisioning:loan-list'))
+
+    else:
+        form = LoanTransferForm()
+
+    return render(request, 'hardware_provisioning/transfer.html', {
+        'form': form,
+        'loan': old_loan,
+    })
+
+
+@login_required
+def loan_list(request):
+    view = 'old' if 'old' in request.GET else ''
+
+    if view == 'old':
+        loans = request.user.loans.finished().order_by('-loan_date_end')
+    else:
+        loans = request.user.loans.running()
+
+    return render(request, 'hardware_provisioning/list.html', {
+        'loans': loans,
+        'view': view,
+    })
+
+
+@login_required
+def loan_detail(request, pk):
+    return render(request, 'hardware_provisioning/detail.html', {})

+ 3 - 3
vpn/templates/vpn/vpn.html

@@ -9,7 +9,7 @@
     <p class="legend">Quand vous aurez terminé vos modifications, cliquez sur <input class="button" type="submit" value="Valider" /></p>
 
     {% for message in messages %}
-    <div class="message{% if message.tags %} {{ message.tags }}{% endif %}">
+    <div class="message eat-up{% if message.tags %} {{ message.tags }}{% endif %}">
         {{ message }}
     </div>
     {% endfor %}
@@ -88,7 +88,7 @@
 
 {% endblock %}
 
-{% block js %}
+{% block extra_js %}
     <script>
         // Bouton génération du mot de passe
         $('#passgen').click(function(){
@@ -120,4 +120,4 @@
         });
 
     </script>
-{% endblock js %}
+{% endblock extra_js %}