Parcourir la source

Merge from upstream

ljf il y a 7 ans
Parent
commit
5714fee780

+ 6 - 1
README.md

@@ -187,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.
 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
 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 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
 `python manage.py charge_subscriptions`: generate invoices (including a
 PDF version) for each subscriber.  You probably want to run this command
 PDF version) for each subscriber.  You probably want to run this command
@@ -201,6 +202,10 @@ You should run this command in a cron job every day.
 `python manage.py offer_subscriptions_count`: Returns subscription count grouped
 `python manage.py offer_subscriptions_count`: Returns subscription count grouped
 by offer type.
 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
 Configuration
 =============
 =============
 
 

+ 1 - 1
coin/billing/admin.py

@@ -5,7 +5,7 @@ from django.contrib import admin
 from django.contrib import messages
 from django.contrib import messages
 from django.http import HttpResponseRedirect
 from django.http import HttpResponseRedirect
 from django.conf.urls import url
 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.filtering_queryset import LimitedAdminInlineMixin
 from coin.billing.models import Invoice, InvoiceDetail, Payment, PaymentAllocation
 from coin.billing.models import Invoice, InvoiceDetail, Payment, PaymentAllocation

+ 1 - 2
coin/billing/migrations/0010_new_billing_system_data.py

@@ -63,11 +63,10 @@ class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
         ('billing', '0009_new_billing_system_schema'),
         ('billing', '0009_new_billing_system_schema'),
-        ('members', '0014_member_balance'),
+        ('members', '0016_merge'),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.RunPython(check_current_state),
         migrations.RunPython(check_current_state),
         migrations.RunPython(forwards),
         migrations.RunPython(forwards),
     ]
     ]
-

+ 8 - 2
coin/billing/models.py

@@ -87,7 +87,10 @@ class InvoiceNumber:
         :rtype: dict
         :rtype: dict
         """
         """
 
 
-        return {'{}__month'.format(field_name): date.month}
+        return {
+            '{}__month'.format(field_name): date.month,
+            '{}__year'.format(field_name): date.year
+        }
 
 
 
 
 class InvoiceQuerySet(models.QuerySet):
 class InvoiceQuerySet(models.QuerySet):
@@ -615,10 +618,13 @@ def payment_deleted(sender, instance, **kwargs):
 
 
     accounting_log.info("Deleted payment %s (Date: %s, Member: %s, Amount: %s, Label: %s)."
     accounting_log.info("Deleted payment %s (Date: %s, Member: %s, Amount: %s, Label: %s)."
                         % (instance.pk, instance.date, instance.member,
                         % (instance.pk, instance.date, instance.member,
-                            instance.amount, instance.label.encode('utf-8')))
+                            instance.amount, instance.label))
 
 
     member = instance.member
     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_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")]
     this_member_payments = [p for p in member.payments.order_by("date")]
 
 

+ 13 - 1
coin/billing/tests.py

@@ -316,7 +316,7 @@ class InvoiceQuerySetTests(TestCase):
     @freeze_time('2016-01-01')
     @freeze_time('2016-01-01')
     def test_number_workflow(self):
     def test_number_workflow(self):
         iv = Invoice.objects.create()
         iv = Invoice.objects.create()
-        self.assertEqual(iv.number, 'DRAFT-1')
+        self.assertEqual(iv.number, 'DRAFT-{}'.format(iv.pk))
         iv.validate()
         iv.validate()
         self.assertRegexpMatches(iv.number, r'2016-01-000001$')
         self.assertRegexpMatches(iv.number, r'2016-01-000001$')
 
 
@@ -328,6 +328,18 @@ class InvoiceQuerySetTests(TestCase):
             Invoice.objects.get_next_invoice_number(datetime.date(2016,1,1)),
             Invoice.objects.get_next_invoice_number(datetime.date(2016,1,1)),
             '2016-01-000002')
             '2016-01-000002')
 
 
+    def test_get_right_year_invoice_number(self):
+        with freeze_time('2016-01-01'):
+            Invoice.objects.create(date=datetime.date(2016, 1, 1)).validate()
+        with freeze_time('2017-01-01'):
+            Invoice.objects.create(date=datetime.date(2017, 1, 1)).validate()
+        with freeze_time('2018-01-01'):
+            Invoice.objects.create(date=datetime.date(2018, 1, 1)).validate()
+
+        self.assertEqual(
+            Invoice.objects.get_next_invoice_number(datetime.date(2017, 1, 1)),
+            '2017-01-000002')
+
     def test_bill_date_is_validation_date(self):
     def test_bill_date_is_validation_date(self):
         bill = Invoice.objects.create(date=datetime.date(2016,1,1))
         bill = Invoice.objects.create(date=datetime.date(2016,1,1))
         self.assertEqual(bill.date, datetime.date(2016,1,1))
         self.assertEqual(bill.date, datetime.date(2016,1,1))

+ 103 - 54
coin/members/admin.py

@@ -5,7 +5,8 @@ from django.shortcuts import render, get_object_or_404
 from django.contrib import admin
 from django.contrib import admin
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.auth.admin import UserAdmin
 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.http import HttpResponseRedirect
 from django.conf.urls import url
 from django.conf.urls import url
 from django.db.models.query import QuerySet
 from django.db.models.query import QuerySet
@@ -13,7 +14,7 @@ from django.core.urlresolvers import reverse
 from django.utils.html import format_html
 from django.utils.html import format_html
 
 
 from coin.members.models import (
 from coin.members.models import (
-    Member, CryptoKey, LdapUser, MembershipFee, OfferSubscription)
+    Member, CryptoKey, LdapUser, MembershipFee, Offer, OfferSubscription, RowLevelPermission)
 from coin.members.membershipfee_filter import MembershipFeeFilter
 from coin.members.membershipfee_filter import MembershipFeeFilter
 from coin.members.forms import AdminMemberChangeForm, MemberCreationForm
 from coin.members.forms import AdminMemberChangeForm, MemberCreationForm
 from coin.utils import delete_selected
 from coin.utils import delete_selected
@@ -35,23 +36,50 @@ class MembershipFeeInline(admin.TabularInline):
 class OfferSubscriptionInline(admin.TabularInline):
 class OfferSubscriptionInline(admin.TabularInline):
     model = OfferSubscription
     model = OfferSubscription
     extra = 0
     extra = 0
-    readonly_fields = ('get_subscription_reference', 'subscription_date', 'resign_date',
-                       'commitment', 'offer', 'show_change_link')
 
 
-    # FIXME: Workaround en attendant la migration vers Django >=1.8
-    # À remplacer par InlineModelAdmin.show_change_link = True
-    def show_change_link(self, obj=None):
-        url = reverse('admin:%s_%s_change' % (obj._meta.app_label,
-                                              obj._meta.model_name),
-                      args=[obj.id])
-        return format_html(u'<a href="{}">Éditer</a>', url)
-    show_change_link.short_description = 'Éditer ?'
+    writable_fields = ('subscription_date', 'resign_date', 'commitment', 'offer')
+    all_fields = ('get_subscription_reference',) + writable_fields
 
 
-    def has_add_permission(self, request, obj=None):
-        return False
+    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):
     def has_delete_permission(self, request, obj=None):
-        return False
+        return request.user.is_superuser
 
 
 
 
 class MemberAdmin(UserAdmin):
 class MemberAdmin(UserAdmin):
@@ -68,43 +96,46 @@ class MemberAdmin(UserAdmin):
     form = AdminMemberChangeForm
     form = AdminMemberChangeForm
     add_form = MemberCreationForm
     add_form = MemberCreationForm
 
 
-    fieldsets = (
-        ('Adhérent', {'fields': (
-            ('status', 'resign_date'),
-            'type',
-            ('first_name', 'last_name', 'nickname'),
-            'organization_name',
-            'comments',
-            'balance')}),
-        ('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'),
             ('home_phone_number', 'mobile_phone_number'),
             'address',
             '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',
-            ('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}
     radio_fields = {"type": admin.HORIZONTAL}
 
 
@@ -112,16 +143,27 @@ class MemberAdmin(UserAdmin):
 
 
     inlines = [CryptoKeyInline, MembershipFeeInline, OfferSubscriptionInline]
     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):
     def get_readonly_fields(self, request, obj=None):
+        readonly_fields = []
         if obj:
         if obj:
             # Remove help_text for readonly field (can't do that in the Form
             # Remove help_text for readonly field (can't do that in the Form
             # django seems to user help_text from model for readonly fields)
             # django seems to user help_text from model for readonly fields)
             username_field = [
             username_field = [
                 f for f in obj._meta.fields if f.name == 'username']
                 f for f in obj._meta.fields if f.name == 'username']
             username_field[0].help_text = ''
             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):
     def set_as_member(self, request, queryset):
         rows_updated = queryset.update(status='member')
         rows_updated = queryset.update(status='member')
@@ -219,7 +261,14 @@ class MembershipFeeAdmin(admin.ModelAdmin):
                     'payment_date')
                     'payment_date')
     form = autocomplete_light.modelform_factory(MembershipFee, fields='__all__')
     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(Member, MemberAdmin)
 admin.site.register(MembershipFee, MembershipFeeAdmin)
 admin.site.register(MembershipFee, MembershipFeeAdmin)
-admin.site.unregister(Group)
+# admin.site.unregister(Group)
 # admin.site.register(LdapUser, LdapUserAdmin)
 # 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,
 autocomplete_light.register(Member,
                             # Just like in ModelAdmin.search_fields
                             # Just like in ModelAdmin.search_fields
                             search_fields=[
                             search_fields=[
-                                '^first_name', 'last_name', 'organization_name',
-                                'username', 'nickname'],
-                            # 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,
+                            },
+)

+ 2 - 1
coin/members/management/commands/call_for_membership_fees.py

@@ -43,7 +43,8 @@ class Command(BaseCommand):
 
 
         members = Member.objects.filter(status='member')\
         members = Member.objects.filter(status='member')\
                                 .annotate(end=Max('membership_fees__end_date'))\
                                 .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:
         if verbosity >= 2:
             self.stdout.write(
             self.stdout.write(
                 "Got {number} members.".format(number=members.count()))
                 "Got {number} members.".format(number=members.count()))

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

@@ -1,15 +1,58 @@
 # -*- coding: utf-8 -*-
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
+import datetime
+
 from django.core.management.base import BaseCommand, CommandError
 from django.core.management.base import BaseCommand, CommandError
+from django.db.models import Q
 
 
 from coin.members.models import Member
 from coin.members.models import Member
-
+from coin.offers.models import Offer
+from coin.offers.models import OfferSubscription
 
 
 class Command(BaseCommand):
 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):
     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:
         for email in emails:
             self.stdout.write(email)
             self.stdout.write(email)

+ 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 = [
+    ]

+ 70 - 3
coin/members/models.py

@@ -9,17 +9,19 @@ from django.db import models
 from django.db.models import Q, Max
 from django.db.models import Q, Max
 from django.db.models.signals import pre_save
 from django.db.models.signals import pre_save
 from django.dispatch import receiver
 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.conf import settings
 from django.core.validators import RegexValidator
 from django.core.validators import RegexValidator
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.utils import timezone
 from django.utils import timezone
 from django.core.mail import send_mail
 from django.core.mail import send_mail
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
+from django.utils.text import slugify
 from ldapdb.models.fields import CharField, IntegerField, ListField
 from ldapdb.models.fields import CharField, IntegerField, ListField
 from registration.signals import user_registered
 from registration.signals import user_registered
 
 
-from coin.offers.models import OfferSubscription
+from coin.offers.models import Offer, OfferSubscription
 from coin.mixins import CoinLdapSyncMixin
 from coin.mixins import CoinLdapSyncMixin
 from coin import utils
 from coin import utils
 
 
@@ -39,6 +41,19 @@ def send_registration_notification(sender, user, request=None, **kwargs):
               settings.DEFAULT_FROM_EMAIL,
               settings.DEFAULT_FROM_EMAIL,
               settings.NOTIFICATION_EMAILS,
               settings.NOTIFICATION_EMAILS,
               fail_silently=False)
               fail_silently=False)
+=======
+
+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()
+>>>>>>> upstream/master
 
 
 
 
 class Member(CoinLdapSyncMixin, AbstractUser):
 class Member(CoinLdapSyncMixin, AbstractUser):
@@ -95,10 +110,13 @@ class Member(CoinLdapSyncMixin, AbstractUser):
     date_last_call_for_membership_fees_email = models.DateTimeField(null=True,
     date_last_call_for_membership_fees_email = models.DateTimeField(null=True,
                         blank=True,
                         blank=True,
                         verbose_name="Date du dernier email de relance de cotisation envoyé")
                         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,
     balance = models.DecimalField(max_digits=5, decimal_places=2, default=0,
                                   verbose_name='account balance')
                                   verbose_name='account balance')
 
 
+    objects = MemberManager()
 
 
     # Following fields are managed by the parent class AbstractUser :
     # Following fields are managed by the parent class AbstractUser :
     # username, first_name, last_name, email
     # username, first_name, last_name, email
@@ -300,6 +318,9 @@ class Member(CoinLdapSyncMixin, AbstractUser):
 
 
         :param auto: is it an auto email? (changes slightly template content)
         :param auto: is it an auto email? (changes slightly template content)
         """
         """
+        if auto and not self.send_membership_fees_email:
+            return False
+
         from dateutil.relativedelta import relativedelta
         from dateutil.relativedelta import relativedelta
         from coin.isp_database.models import ISPInfo
         from coin.isp_database.models import ISPInfo
 
 
@@ -510,6 +531,7 @@ class LdapUser(ldapdb.models.Model):
 # managed = False  # Indique à Django de ne pas intégrer ce model en base
 # managed = False  # Indique à Django de ne pas intégrer ce model en base
 
 
 
 
+
 @receiver(pre_save, sender=Member)
 @receiver(pre_save, sender=Member)
 def define_username(sender, instance, **kwargs):
 def define_username(sender, instance, **kwargs):
     """
     """
@@ -529,3 +551,48 @@ def define_display_name(sender, instance, **kwargs):
     if not instance.display_name:
     if not instance.display_name:
         instance.display_name = '%s %s' % (instance.first_name,
         instance.display_name = '%s %s' % (instance.first_name,
                                            instance.last_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"

+ 26 - 1
coin/offers/admin.py

@@ -2,9 +2,12 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
 from django.contrib import admin
 from django.contrib import admin
+from django.db.models import Q
 from polymorphic.admin import PolymorphicChildModelAdmin
 from polymorphic.admin import PolymorphicChildModelAdmin
 
 
 from coin.offers.models import Offer, OfferIPPool, OfferSubscription
 from coin.offers.models import Offer, OfferIPPool, OfferSubscription
+from coin.members.models import Member
+
 from coin.offers.offersubscription_filter import\
 from coin.offers.offersubscription_filter import\
             OfferSubscriptionTerminationFilter,\
             OfferSubscriptionTerminationFilter,\
             OfferSubscriptionCommitmentFilter
             OfferSubscriptionCommitmentFilter
@@ -50,7 +53,29 @@ class OfferSubscriptionAdmin(admin.ModelAdmin):
                 'resign_date',
                 'resign_date',
                 'comments'
                 '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):
     def get_inline_instances(self, request, obj=None):
         """
         """

+ 0 - 19
coin/offers/management/commands/subscribers_email.py

@@ -1,19 +0,0 @@
-# -*- 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.offers.models import OfferSubscription
-
-
-class Command(BaseCommand):
-    help = 'Returns the email addresses of all subscribers, in a format suitable for bulk importing in Sympa'
-
-    def handle(self, *args, **options):
-        emails = [s.member.email for s in OfferSubscription.objects.filter(Q(resign_date__gt=datetime.date.today) | Q(resign_date__isnull=True))]
-        # Use a set to ensure uniqueness
-        for email in set(emails):
-            self.stdout.write(email)

+ 18 - 0
coin/offers/models.py

@@ -7,10 +7,26 @@ from django.conf import settings
 from django.db import models
 from django.db import models
 from django.db.models import Count, Q
 from django.db.models import Count, Q
 from django.core.validators import MinValueValidator
 from django.core.validators import MinValueValidator
+from django.contrib.contenttypes.models import ContentType
 
 
 from coin.resources.models import IPPool
 from coin.resources.models import IPPool
 
 
 
 
+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):
 class Offer(models.Model):
     """Description of an offer available to subscribers.
     """Description of an offer available to subscribers.
 
 
@@ -48,6 +64,8 @@ class Offer(models.Model):
 
 
     ip_pools = models.ManyToManyField(IPPool, through='OfferIPPool')
     ip_pools = models.ManyToManyField(IPPool, through='OfferIPPool')
 
 
+    objects = OfferManager()
+
     def get_configuration_type_display(self):
     def get_configuration_type_display(self):
         """
         """
         Renvoi le nom affichable du type de configuration
         Renvoi le nom affichable du type de configuration

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

+ 50 - 2
hardware_provisioning/admin.py

@@ -5,9 +5,12 @@ from __future__ import unicode_literals
 
 
 from django.contrib import admin
 from django.contrib import admin
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
+from django.forms import ModelChoiceField
 from django.utils import timezone
 from django.utils import timezone
+import autocomplete_light
 
 
 from .models import ItemType, Item, Loan, Storage
 from .models import ItemType, Item, Loan, Storage
+import coin.members.admin
 
 
 
 
 User = get_user_model()
 User = get_user_model()
@@ -40,6 +43,7 @@ class AvailabilityFilter(admin.SimpleListFilter):
         return [
         return [
             ('available', 'Disponible'),
             ('available', 'Disponible'),
             ('borrowed', 'Emprunté'),
             ('borrowed', 'Emprunté'),
+            ('deployed', 'Déployé'),
         ]
         ]
 
 
     def queryset(self, request, queryset):
     def queryset(self, request, queryset):
@@ -47,6 +51,8 @@ class AvailabilityFilter(admin.SimpleListFilter):
             return queryset.available()
             return queryset.available()
         elif self.value() == 'borrowed':
         elif self.value() == 'borrowed':
             return queryset.borrowed()
             return queryset.borrowed()
+        elif self.value() == 'deployed':
+            return queryset.deployed()
         else:
         else:
             return queryset
             return queryset
 
 
@@ -55,7 +61,7 @@ class AvailabilityFilter(admin.SimpleListFilter):
 class ItemAdmin(admin.ModelAdmin):
 class ItemAdmin(admin.ModelAdmin):
     list_display = (
     list_display = (
         'designation', 'type', 'mac_address', 'serial', 'owner',
         'designation', 'type', 'mac_address', 'serial', 'owner',
-        'buy_date', 'is_available')
+        'buy_date', 'deployed', 'is_available')
     list_filter = (
     list_filter = (
         AvailabilityFilter, 'type__name', 'storage',
         AvailabilityFilter, 'type__name', 'storage',
         'buy_date', OwnerFilter)
         'buy_date', OwnerFilter)
@@ -63,8 +69,11 @@ class ItemAdmin(admin.ModelAdmin):
         'designation', 'mac_address', 'serial',
         'designation', 'mac_address', 'serial',
         'owner__email', 'owner__nickname',
         'owner__email', 'owner__nickname',
         'owner__first_name', 'owner__last_name')
         'owner__first_name', 'owner__last_name')
+    save_as = True
     actions = ['give_back']
     actions = ['give_back']
 
 
+    form = autocomplete_light.modelform_factory(Loan, fields='__all__')
+
     def give_back(self, request, queryset):
     def give_back(self, request, queryset):
         for item in queryset.filter(loans__loan_date_end=None):
         for item in queryset.filter(loans__loan_date_end=None):
             item.give_back()
             item.give_back()
@@ -119,9 +128,15 @@ class BorrowerFilter(admin.SimpleListFilter):
             return queryset
             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)
 @admin.register(Loan)
 class LoanAdmin(admin.ModelAdmin):
 class LoanAdmin(admin.ModelAdmin):
-    list_display = ('item', 'user', 'loan_date', 'loan_date_end')
+    list_display = ('item', 'get_mac_and_serial', 'user', 'loan_date', 'loan_date_end')
     list_filter = (StatusFilter, BorrowerFilter, 'item__designation')
     list_filter = (StatusFilter, BorrowerFilter, 'item__designation')
     search_fields = (
     search_fields = (
         'item__designation',
         'item__designation',
@@ -134,6 +149,15 @@ class LoanAdmin(admin.ModelAdmin):
             loan_date_end=datetime.now())
             loan_date_end=datetime.now())
     end_loan.short_description = 'Mettre fin au prêt'
     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)
 @admin.register(Storage)
 class StorageAdmin(admin.ModelAdmin):
 class StorageAdmin(admin.ModelAdmin):
@@ -145,3 +169,27 @@ class StorageAdmin(admin.ModelAdmin):
         else:
         else:
             return obj.notes
             return obj.notes
     truncated_notes.short_description = '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)

+ 19 - 0
hardware_provisioning/migrations/0017_item_deployed.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('hardware_provisioning', '0016_auto_20170802_2021'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='item',
+            name='deployed',
+            field=models.BooleanField(default=False, help_text='Cocher si le mat\xe9riel est en production', verbose_name='d\xe9ploy\xe9'),
+        ),
+    ]

+ 36 - 6
hardware_provisioning/models.py

@@ -2,6 +2,7 @@
 
 
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 from django.db import models
 from django.db import models
+from django.db.models import Q
 from django.conf import settings
 from django.conf import settings
 from django.utils import timezone
 from django.utils import timezone
 
 
@@ -24,11 +25,22 @@ class ItemQuerySet(models.QuerySet):
         return Loan.objects.running().values_list('item', flat=True)
         return Loan.objects.running().values_list('item', flat=True)
 
 
     def available(self):
     def available(self):
-        return self.exclude(pk__in=self._get_borrowed_pks())
+        return self.exclude(
+            pk__in=self._get_borrowed_pks()).exclude(deployed=True)
 
 
     def borrowed(self):
     def borrowed(self):
         return self.filter(pk__in=self._get_borrowed_pks())
         return self.filter(pk__in=self._get_borrowed_pks())
 
 
+    def deployed(self):
+        return self.filter(deployed=True)
+
+    def unavailable(self):
+        """ deployed or borrowed
+        """
+        return self.filter(
+            Q(pk__in=self._get_borrowed_pks()) |
+            Q(deployed=True))
+
 
 
 class Item(models.Model):
 class Item(models.Model):
     type = models.ForeignKey(ItemType, verbose_name='type de matériel',
     type = models.ForeignKey(ItemType, verbose_name='type de matériel',
@@ -54,6 +66,8 @@ class Item(models.Model):
         related_name='items',
         related_name='items',
         null=True, blank=True,
         null=True, blank=True,
         help_text="dans le cas de matériel n'appartenant pas à l'association")
         help_text="dans le cas de matériel n'appartenant pas à l'association")
+    deployed = models.BooleanField(verbose_name='déployé', default=False,
+                                   help_text='Cocher si le matériel est en production')
     comment = models.TextField(verbose_name='commentaire', blank=True,
     comment = models.TextField(verbose_name='commentaire', blank=True,
                                null=True)
                                null=True)
 
 
@@ -81,15 +95,21 @@ class Item(models.Model):
 
 
     def is_available(self):
     def is_available(self):
         """
         """
-        Returns the status of the Item. If a Loan without an end date exists,
-        returns False (else True).
+        Returns the status of the Item. If a running loan exists,
+        or if the item is deployed, returns False (else True).
         """
         """
-        if self.loans.running().exists():
-            return False
-        return True
+        return (not self.deployed) and (not self.loans.running().exists())
     is_available.boolean = True
     is_available.boolean = True
     is_available.short_description = 'disponible'
     is_available.short_description = 'disponible'
 
 
+    def get_mac_and_serial(self):
+        mac = self.mac_address
+        serial = self.serial
+        if mac and serial:
+            return "{} / {}".format(mac, serial)
+        else:
+            return mac or serial or ''
+
     class Meta:
     class Meta:
         verbose_name = 'objet'
         verbose_name = 'objet'
 
 
@@ -128,9 +148,19 @@ class Loan(models.Model):
         return 'prêt de {item} à {user}'.format(
         return 'prêt de {item} à {user}'.format(
             item=self.item, user=self.user)
             item=self.item, user=self.user)
 
 
+    def get_mac_and_serial(self):
+        return self.item.get_mac_and_serial()
+
+    get_mac_and_serial.short_description = "Adresse MAC / n° de série"
+
     def user_can_close(self, user):
     def user_can_close(self, user):
         return (not self.item.is_available()) and (self.user == user)
         return (not self.item.is_available()) and (self.user == user)
 
 
+    def is_running(self):
+        return not self.loan_date_end or self.loan_date_end > timezone.now()
+    is_running.boolean = True
+    is_running.short_description = 'En cours ?'
+
     class Meta:
     class Meta:
         verbose_name = 'prêt d’objet'
         verbose_name = 'prêt d’objet'
         verbose_name_plural = 'prêts d’objets'
         verbose_name_plural = 'prêts d’objets'

+ 46 - 11
hardware_provisioning/tests.py

@@ -14,20 +14,30 @@ def localize(naive_dt):
         return timezone.make_aware(naive_dt, timezone.get_current_timezone())
         return timezone.make_aware(naive_dt, timezone.get_current_timezone())
 
 
 
 
-class HardwareLoaningTestCase(TestCase):
+class HardwareModelsFactoryMixin:
+    def get_item_type(self, **kwargs):
+        params = {'name': 'Foos'}
+        params.update(**kwargs)
+        item_type, _ = ItemType.objects.get_or_create(**kwargs)
+        return item_type
+
+    def get_item(self, **kwargs):
+        params = {
+            'type': self.get_item_type(),
+            'designation': 'Test item',
+        }
+        params.update(**kwargs)
+        item, _ = Item.objects.get_or_create(**params)
+        return item
+
+
+class HardwareLoaningTestCase(HardwareModelsFactoryMixin, TestCase):
     def setUp(self):
     def setUp(self):
-        self.member = Member.objects.create(
-            first_name='John',
-            last_name='Doe',
-            username='jdoe')
-        self.item_type = ItemType.objects.create(name='Foos')
-        self.item = Item.objects.create(
-            type=self.item_type,
-            designation='Bar Wheel',
-            buy_date=date(2012,12,5))
+        self.member = Member.objects.create(username='jdoe')
+        self.item = self.get_item()
 
 
     def test_running_(self):
     def test_running_(self):
-        loan_start_date = localize(datetime(2011,1,14,12,0,0))
+        loan_start_date = localize(datetime(2011, 1, 14, 12, 0, 0))
         loan = Loan.objects.create(
         loan = Loan.objects.create(
             item=self.item, user=self.member,
             item=self.item, user=self.member,
             loan_date=loan_start_date)
             loan_date=loan_start_date)
@@ -37,3 +47,28 @@ class HardwareLoaningTestCase(TestCase):
         loan.item.give_back()
         loan.item.give_back()
         self.assertEqual(Loan.objects.running().count(), 0)
         self.assertEqual(Loan.objects.running().count(), 0)
         self.assertEqual(Loan.objects.finished().count(), 1)
         self.assertEqual(Loan.objects.finished().count(), 1)
+
+
+class ItemTestCase(HardwareModelsFactoryMixin, TestCase):
+    def setUp(self):
+        self.member = Member.objects.create(username='jdoe')
+
+        self.free_item = self.get_item(designation='free')
+        self.deployed_item = self.get_item(
+            designation='deployed', deployed=True)
+        self.borrowed_item = self.get_item(designation='borrowed')
+
+    def test_queryset_methods(self):
+        self.assertEqual(Item.objects.borrowed().count(), 0)
+        self.assertEqual(Item.objects.deployed().count(), 1)
+        self.assertEqual(Item.objects.available().count(), 2)
+        self.assertEqual(Item.objects.unavailable().count(), 1)
+
+        Loan.objects.create(
+            item=self.borrowed_item, user=self.member,
+            loan_date=localize(datetime(2011, 1, 14, 12, 0, 0)))
+
+        self.assertEqual(Item.objects.borrowed().count(), 1)
+        self.assertEqual(Item.objects.deployed().count(), 1)
+        self.assertEqual(Item.objects.available().count(), 1)
+        self.assertEqual(Item.objects.unavailable().count(), 2)

+ 1 - 1
requirements.txt

@@ -3,7 +3,7 @@ psycopg2==2.5.2
 python-ldap==2.4.15
 python-ldap==2.4.15
 wsgiref==0.1.2
 wsgiref==0.1.2
 python-dateutil==2.2
 python-dateutil==2.2
-django-autocomplete-light==2.1.1
+django-autocomplete-light>=2.2.10,<2.3
 django-activelink==0.4
 django-activelink==0.4
 html2text
 html2text
 django-polymorphic==0.7.2
 django-polymorphic==0.7.2