36 Commits aedfb2863e ... d4c8671914

Author SHA1 Message Date
  SimonBoulier d4c8671914 Level permissions 7 years ago
  SimonBoulier f84689cd1d Autocomplétion pour le champ membre dans la déclaration d'un emprunt. 7 years ago
  SimonBoulier 8545f768a9 Typo: get_mac_or_serial instead of get_mac_and_serial, raise an error when creating a new member 7 years ago
  Jocelyn Delalande bbb9e1a517 Partly reverts f18aa84 7 years ago
  jocelyn aa8229d38e Merge branch 'change_info' of ARN/coin into master 7 years ago
  jocelyn b2c47517a6 Merge branch 'feat-list-loan-in-MemberAdmin-view' of daimrod/coin into master 7 years ago
  Jocelyn Delalande 878aa4fbd6 Order loans in member admin to display running loans first 7 years ago
  Jocelyn Delalande 6664144b31 Display wether a loan is running or not in member admin 7 years ago
  Jocelyn Delalande 40f4120b16 Add a Loan.is_running property 7 years ago
  daimrod 369f9b8eed Merge branch 'feat-option-to-disable-fee-reminders' of daimrod/coin into master 7 years ago
  Grégoire Jadi 8ebe239ee9 Check if we should to send the membership fees email from the model 7 years ago
  Grégoire Jadi 2e54d53692 Add checkbox to enable/disable membership fees reminder 7 years ago
  daimrod 3fa14674b9 Merge branch 'feat-cmd-list-members' of daimrod/coin into master 7 years ago
  Grégoire Jadi 3a60f81e11 Add a small note about members_email new filters in README 7 years ago
  Grégoire Jadi 47bc4151a8 BREAKING CHANGE! Remove subscribers_email superseeded by members_email --subscribers 7 years ago
  jocelyn 58f3a3310d Merge branch 'save_as_admin_item' of Sim/coin into master 7 years ago
  daimrod df7da50c33 Merge branch 'fix-manage_py-warnings' of daimrod/coin into master 7 years ago
  Grégoire Jadi 9277b03b21 Set upper bound to django-autocomplete-light's version instead of fixing it 7 years ago
  SimonBoulier aedfb2863e Un peu. J'ai créé un fichier TODO.md 7 years ago
  SimonBoulier 436fc8a999 TODO: ne pas pouvoir ajouter un abo à n'importe qui, readme, notifs au bureau, ne pas pouvoir chger un user d'un abo, check daimrod place date inscription et fin, faire tester la secu, migrations propres 7 years ago
  Grégoire Jadi bafefc478e Fix Django19Warning : The django.contrib.admin.util module has been renamed 7 years ago
  Grégoire Jadi 172e5c45bf Bump to django-autocomplete-light to fix taggit warnings in manage.py 7 years ago
  Grégoire Jadi 5e5ca51d29 Improve members email listing 7 years ago
  SimonBoulier 04216fd7a1 Partial solution to #79 : For an item of the hardware provisioning, replace 'Save and add another' by 'Save as new' which duplicate the item 7 years ago
  Grégoire Jadi 35837b9396 Fix#119 : hardware_provisioning: List loan in member view 7 years ago
  ljf 8280ce6bfb [fix] Conflict 7 years ago
  ljf 51b6f94b68 [enh] Use render instead of render_response 7 years ago
  ljf 09e334c1ab [fix] Separate change form to fix admin error 7 years ago
  ljf 8e76d97c7a [enh] Add documentation about profile edition 7 years ago
  ljf 89467fc2cb [fix] Rename profil as profile 7 years ago
  ljf 974963e878 [enh] Member can update their info 8 years ago
  ljf 91ab649ba9 [enh] Use render instead of render_response 7 years ago
  ljf 95fc516c88 [fix] Separate change form to fix admin error 7 years ago
  ljf c8888fd335 [enh] Add documentation about profile edition 7 years ago
  ljf f6d1ad3bab [fix] Rename profil as profile 7 years ago
  ljf 294f93f48e [enh] Member can update their info 8 years ago

+ 37 - 10
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.
 Note that membership is based on the `status` field of users, not on
 membership fees.  That is, even if a member has forgot to renew his or her
-membership fee, his or her address will still show up in this list.
+membership fee, his or her address will still show up in this list. More
+filters are available, see the command's help for more details.
 
 `python manage.py charge_subscriptions`: generate invoices (including a
 PDF version) for each subscriber.  You probably want to run this command
@@ -328,15 +329,40 @@ If you enable an extra-app after initial installation, make sure to sync databas
 Permissions
 ===========
 
-Exemple pour un groupe gérant les membres souscrivant à l'abonnememnt 'wifi-bottière' :
-1 - Créer une Row Level Permisison 'wifibot-perm' avec Content Type : abonnement, Offre : 'wifi-bottière'
-2 - Créer un groupe 'wifibot-group' avec les perms :
-	- membres | membre | Can add membre
-	- membres | membre | Can change membre
-	- offers | abonnement | Can add abonnement
-	- offers | abonnement | Can change abonnement
-	- offers | abonnement | wifibot-perm
-3 - Donner ce groupe aux utilisateurs concernés
+Il est possible de donner des permissions à certains utilisateurs pour qu'ils puissent avoir accès à une partie de seulement de l'interface d'administration.
+
+## Exemple pour un groupe gérant le matos.
+
+1 - Créer un groupe (dans Auth) 'Matos' avec toutes les permissions de l'application 'hardware_provisioning'.
+
+2 - Pour chaque bénévole qui va gérer le matos, aller sur la page qui permet de modifier ses informations et dans la rubrique 'Permissions' :
+	- lui ajouter le 'Statut équipe'
+	- l'ajouter au groupe 'Matos'
+
+Quand on déclare un nouvel emprunt, il faut taper au moins 4 caractères du nom du membre qui emprunte, de cette façon un utilisateur qui n'est pas superuser 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 Members) correspondante (c'est pénible mais on est obligé de faire une permission par abonnement). Par exemple :
+    - Nom : 'Permission ADSL Marque blanche', Content Type : 'abonnement', Nom de code : 'perm-adsl-marque-blanche', Offre : Marque blanche FDN - 32 € / mois'
+    - Nom : 'Permission ADSL Marque blanche (préférentiel)', Content Type : 'abonnement', Nom de code : 'perm-adsl-marque-blanche-pref', Offre : Marque blanche FDN - 28 € / mois (préférentiel)'
+
+2 - Créer un 'Groupe' (dans Auth) 'ADSL' avec les permissions suivantes :
+	- membres | membre | Can add membre                  # pour que les bénévoles puissent ajouter de nouveaux membres
+	- membres | membre | Can change membre			     # pour qu'ils puissent é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 ajouter un abonnement
+	- offers | abonnement | Can change abonnement	     # pour qu'ils puissent modifier un abonnement
+	- offers | abonnement | Can delete abonnement	     # si l'on veut qu'ils puissent supprimer des abonnements (mais ça marche très bien sans)
+	- offers | abonnement | 'perm-adsl-marque-blanche'	 # pour qu'ils puissent avoir accès aux membres qui ont souscrit à l'offre correspondante
+	- offers | abonnement | 'perm-adsl-marque-blanche-pref'
+
+3 - Pour chaque bénévole qui va gérer l'ADSL, aller sur la page qui permet de modifier ses informations et dans la rubrique 'Permissions' :
+	- lui ajouter le 'Statut équipe'
+	- l'ajouter au groupe 'ADSL'
+
+Les bénévoles du groupe peuvent maintenant ajouter / modifier des membres et des abonnements. Attention : pour respecter la vie privée, ils n'ont accès qu'aux membres qui ont un abonnement ADSL. Donc s'ils veulent ajouter un nouveau membre il faut ajouter son abonnement *au moment de la création du membre* ('Ajouter un objet Abonnement supplémentaire' dans la section Abonnements) sinon le membre va être créé mais ils n'y auront plus accès !
+
+
 
 Settings
 ========
@@ -349,6 +375,7 @@ List of available settings in your `settings_local.py` file.
 - `MEMBER_MEMBERSHIP_INFO_URL`: Link to a page with information on how to become a member or pay the membership fee
 - `SUBSCRIPTION_REFERENCE`: Pattern used to display a unique reference for any subscription. Helpful for bank wire transfer identification
 - `PAYMENT_DELAY`: Payment delay in days for issued invoices ( default is 30 days which is the default in french law)
+- `MEMBER_CAN_EDIT_PROFILE`: Allow members to edit their profiles
 
 More information
 ================

+ 15 - 5
TODO.md

@@ -1,11 +1,21 @@
+
+[x] lister membres auquels on a accès
+[x] modification membre auquel on a accès
+
+[x] lister abo auquels on a accès
+[x] ajout / modification d'abonnement (seulement pour les membres / abo auquel on a accès) ok
+
 [x] pouvoir ajouter un abonnement en créant un membre
-[x] ne pas pouvoir ajouter un abonnement à un membre existant
-[x] ne pas pouvoir chger un user d'un abo
+[x] que ce soit seulement un abo auquel on a accès
+
+[x] virer (ou faire fonctionner) le bouton 'Ajouter un abonnement' de la page de modification d'un membre qui fait planter
+
+[x] autocomplete dans prêt objet
+[ ] plusieurs row dans une RowLevel permerm
+
+[ ] commentaire mieux
 [ ] readme
 [ ] notifs au bureau
 [ ] check daimrod place date inscription et fin
 [ ] faire tester la secu
 [ ] migrations propres
-
-Pour le futur :
-[ ] virer (ou faire fonctionner) le bouton 'Ajouter un abonnement' de la page de modification d'un membre

+ 1 - 1
coin/billing/admin.py

@@ -5,7 +5,7 @@ from django.contrib import admin
 from django.contrib import messages
 from django.http import HttpResponseRedirect
 from django.conf.urls import url
-from django.contrib.admin.util import flatten_fieldsets
+from django.contrib.admin.utils import flatten_fieldsets
 
 from coin.filtering_queryset import LimitedAdminInlineMixin
 from coin.billing.models import Invoice, InvoiceDetail, Payment

+ 28 - 15
coin/members/admin.py

@@ -13,9 +13,9 @@ from django.core.urlresolvers import reverse
 from django.utils.html import format_html
 
 from coin.members.models import (
-    Member, CryptoKey, LdapUser, MembershipFee, OfferSubscription, RowLevelPermission)
+    Member, CryptoKey, LdapUser, MembershipFee, Offer, OfferSubscription, RowLevelPermission)
 from coin.members.membershipfee_filter import MembershipFeeFilter
-from coin.members.forms import MemberChangeForm, MemberCreationForm
+from coin.members.forms import AdminMemberChangeForm, MemberCreationForm
 from coin.utils import delete_selected
 import autocomplete_light
 
@@ -45,6 +45,8 @@ class OfferSubscriptionInline(admin.TabularInline):
         else:
             return self.common_fields
 
+    # création de membre : lecture écriture
+    # modification d'un membre : lecture seule seulement
     def get_readonly_fields(self, request, obj=None):
         if obj:
             return self.all_fields
@@ -53,8 +55,27 @@ class OfferSubscriptionInline(admin.TabularInline):
 
     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":
+                # pouah c'est pas beau, faut faire mieux, ça serait bien que get_manageable_offers renvoie un QuerrySet plutôt qu'une liste
+                kwargs["queryset"] = Offer.objects.filter(id__in=[p.id for p in RowLevelPermission.get_manageable_offers(request.user)])
+            return super(OfferSubscriptionInline, self).formfield_for_foreignkey(db_field, request, **kwargs)
+
+    # pas très beau
+    # quand on créer un membre on autorise à ajouter un abonnement
+    # sinon seulement en lecture seule (sinon ça permettrait de changer les abo qu'on n'a pas le droit de toucher)
+    def has_add_permission(self, request):
+        if request.path.split('/')[-2] == 'add':
+            return True
+        else:
+            return request.user.is_superuser
+
+    # sinon on pourrait supprimer les abo qu'on ne peut pas gérer
     def has_delete_permission(self, request, obj=None):
-        return False
+        return request.user.is_superuser
 
 
 class MemberAdmin(UserAdmin):
@@ -68,21 +89,17 @@ class MemberAdmin(UserAdmin):
     actions = [delete_selected, 'set_as_member', 'set_as_non_member',
                'bulk_send_welcome_email', 'bulk_send_call_for_membership_fee_email']
 
-    form = MemberChangeForm
+    form = AdminMemberChangeForm
     add_form = MemberCreationForm
 
     def get_fieldsets(self, request, obj=None):
         coord_fieldset = ('Coordonnées', {'fields': (
-            'email',
+            ('email', 'send_membership_fees_email'),
             ('home_phone_number', 'mobile_phone_number'),
             'address',
             ('postal_code', 'city', 'country'))})
-        if request.user.is_superuser:
-            auth_fieldset = ('Authentification', {'fields': (
-                ('username', 'password'))})
-        else:
-            auth_fieldset = ('Authentification', {'fields': (
-                ('username',))})
+        auth_fieldset = ('Authentification', {'fields': (
+            ('username', 'password'))})
         perm_fieldset = ('Permissions', {'fields': (
             ('is_active', 'is_staff', 'is_superuser', 'groups'))})
 
@@ -238,10 +255,6 @@ class MembershipFeeAdmin(admin.ModelAdmin):
     form = autocomplete_light.modelform_factory(MembershipFee, fields='__all__')
 
 
-class PermisionAdmin(admin.ModelAdmin):
-    pass
-
-
 admin.site.register(Member, MemberAdmin)
 admin.site.register(MembershipFee, MembershipFeeAdmin)
 # admin.site.unregister(Group)

+ 8 - 5
coin/members/autocomplete_light_registry.py

@@ -10,8 +10,11 @@ autocomplete_light.register(Member,
                             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 ?', },
-                            )
+                            attrs={
+                                # This will set the input placeholder attribute:
+                                'placeholder': 'Nom/Prénom/Pseudo (min 4 caractères)',
+                                # Nombre minimum de caractères à saisir avant de compléter.
+                                # Fixé à 4 pour ne pas qu'on puisse avoir accès à la liste de tous les membres facilement quand on n'est pas superuser. Peut-être que 3 serait plus convénient.
+                                'data-autocomplete-minimum-characters': 4,
+                            },
+)

+ 50 - 4
coin/members/forms.py

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

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

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

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

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

+ 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'),
+        ),
+    ]

+ 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',),
+        ),
+    ]

+ 19 - 0
coin/members/models.py

@@ -77,6 +77,10 @@ class Member(CoinLdapSyncMixin, AbstractUser):
                         blank=True,
                         verbose_name="Date du dernier email de relance de cotisation envoyé")
 
+    send_membership_fees_email = models.BooleanField(
+        default=True, verbose_name='relance de cotisation',
+        help_text='Précise si l\'utilisateur doit recevoir des mails de relance pour la cotisation. Certains membres n\'ont pas à recevoir de relance (prélèvement automatique, membres d\'honneurs, etc.)')
+
     # Following fields are managed by the parent class AbstractUser :
     # username, first_name, last_name, email
     # However we hack the model to force theses fields to be required. (see
@@ -277,6 +281,9 @@ class Member(CoinLdapSyncMixin, AbstractUser):
 
         :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 coin.isp_database.models import ISPInfo
 
@@ -507,6 +514,18 @@ class RowLevelPermission(Permission):
         offers = [p.offer for p in rowperms]
         return offers
 
+    @classmethod
+    def get_manageable_users(cls, user):
+        """" Renvoie la liste des members que l'utilisateur est autorisé à voir
+        dans l'interface d'administration.
+        """
+        if user.is_superuser:
+            return Member.objects.all()
+        else:
+            offers = RowLevelPermission.get_manageable_offers(user)
+            return Member.objects.filter(offersubscription__offer__in=offers).distinct()
+
+
 
 @receiver(pre_save, sender=Member)
 def define_username(sender, instance, **kwargs):

+ 18 - 8
coin/members/templates/members/detail.html

@@ -98,14 +98,24 @@
 </div>
 <div class="row">
     <div class="large-12 columns">
-        <p>
-            Pour modifier vos informations personnelles et vos coordonnées, veuillez en faire la demande
-            {% if branding.administrative_email %}
-             par email à <a href="mailto:{{ branding.administrative_email }}">{{ branding.administrative_email }}</a>.
-            {% else %}
-             à l'association.
-            {% endif%}
-        </p>
+        {% if form %}
+            <form method="post" action="">
+                {% csrf_token %}
+                <fieldset class="module aligned wide">
+                {{ form.as_p }}
+                </fieldset>
+                <input type="submit" class="button radius" value="Modifier"/>
+            </form>
+        {% else %}
+            <p>
+                Pour modifier vos informations personnelles et vos coordonnées, veuillez en faire la demande
+                {% if branding.administrative_email %}
+                par email à <a href="mailto:{{ branding.administrative_email }}">{{ branding.administrative_email }}</a>.
+                {% else %}
+                à l'association.
+                {% endif%}
+            </p>
+        {% endif %}
     </div>
 </div>
 

+ 23 - 5
coin/members/views.py

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

+ 15 - 7
coin/offers/admin.py

@@ -47,17 +47,25 @@ class OfferSubscriptionAdmin(admin.ModelAdmin):
                 'resign_date',
                 'comments'
              )
-    form = autocomplete_light.modelform_factory(OfferSubscription, fields='__all__')
+    # Si c'est un super user on renvoie un formulaire avec tous les membres et toutes les offres (donc autocomplétion pour les membres)
+    def get_form(self, request, obj=None, **kwargs):
+        if request.user.is_superuser:
+            kwargs['form'] = autocomplete_light.modelform_factory(OfferSubscription, fields='__all__')
+        return super(OfferSubscriptionAdmin, self).get_form(request, obj, **kwargs)
 
-    def get_readonly_fields(self, request, obj=None):
+    # Si pas super user on restreint les membres et offres accessibles
+    def formfield_for_foreignkey(self, db_field, request, **kwargs):
         if request.user.is_superuser:
-            return ()
+            return super(OfferSubscriptionAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)
         else:
-            return ('member', 'offer')
-
-    def has_add_permission(self, request, obj=None):
-        return request.user.is_superuser
+            if db_field.name == "member":
+                kwargs["queryset"] = RowLevelPermission.get_manageable_users(request.user)
+            if db_field.name == "offer":
+                # pouah c'est pas beau, faut faire mieux, ça serait bien que get_manageable_offers renvoie un QuerrySet plutôt qu'une liste
+                kwargs["queryset"] = Offer.objects.filter(id__in=[p.id for p in RowLevelPermission.get_manageable_offers(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:

+ 1 - 1
coin/offers/forms.py

@@ -11,4 +11,4 @@ class OfferAdminForm(ModelForm):
         widgets = {
             'configuration_type': Select(choices=(('','---------'),) + Configuration.get_configurations_choices_list())
         }
-        exclude = ('', )
+        exclude = ('', )

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

+ 3 - 0
coin/settings_base.py

@@ -263,3 +263,6 @@ FEEDS = (
     ('ffdn', 'http://www.ffdn.org/fr/rss.xml', 3),
 #    ('isp', 'http://isp.example.com/feed/', 3),
 )
+
+# Member can edit their own data
+MEMBER_CAN_EDIT_PROFILE = False

+ 44 - 2
hardware_provisioning/admin.py

@@ -5,9 +5,12 @@ from __future__ import unicode_literals
 
 from django.contrib import admin
 from django.contrib.auth import get_user_model
+from django.forms import ModelChoiceField
 from django.utils import timezone
+import autocomplete_light
 
 from .models import ItemType, Item, Loan, Storage
+from coin.members.admin import MemberAdmin
 
 
 User = get_user_model()
@@ -55,7 +58,7 @@ class AvailabilityFilter(admin.SimpleListFilter):
 class ItemAdmin(admin.ModelAdmin):
     list_display = (
         'designation', 'type', 'mac_address', 'serial', 'owner',
-        'buy_date', 'is_available')
+        'buy_date', 'deployed', 'is_available')
     list_filter = (
         AvailabilityFilter, 'type__name', 'storage',
         'buy_date', OwnerFilter)
@@ -63,6 +66,7 @@ class ItemAdmin(admin.ModelAdmin):
         'designation', 'mac_address', 'serial',
         'owner__email', 'owner__nickname',
         'owner__first_name', 'owner__last_name')
+    save_as = True
     actions = ['give_back']
 
     def give_back(self, request, queryset):
@@ -119,9 +123,15 @@ class BorrowerFilter(admin.SimpleListFilter):
             return queryset
 
 
+class ItemChoiceField(ModelChoiceField):
+    # On surcharge cette méthode pour afficher mac et n° de série dans le menu
+    # déroulant de sélection d'un objet dans la création d'un prêt.
+    def label_from_instance(self, obj):
+        return obj.designation + ' ' + obj.get_mac_and_serial()
+
 @admin.register(Loan)
 class LoanAdmin(admin.ModelAdmin):
-    list_display = ('item', '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')
     search_fields = (
         'item__designation',
@@ -134,6 +144,15 @@ class LoanAdmin(admin.ModelAdmin):
             loan_date_end=datetime.now())
     end_loan.short_description = 'Mettre fin au prêt'
 
+    form = autocomplete_light.modelform_factory(Loan, fields='__all__')
+
+    def formfield_for_foreignkey(self, db_field, request, **kwargs):
+        if db_field.name == 'item':
+            kwargs['queryset'] = Item.objects.all()
+            return ItemChoiceField(**kwargs)
+        else:
+            return super(LoanAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)
+
 
 @admin.register(Storage)
 class StorageAdmin(admin.ModelAdmin):
@@ -145,3 +164,26 @@ class StorageAdmin(admin.ModelAdmin):
         else:
             return obj.notes
     truncated_notes.short_description = 'notes'
+
+class LoanInline(admin.TabularInline):
+    model = Loan
+    extra = 0
+    exclude = ('notes',)
+    readonly_fields = ('item', 'get_mac_and_serial', 'loan_date', 'loan_date_end', 'is_running')
+
+    show_change_link = True
+
+    def get_queryset(self, request):
+        qs = super(LoanInline, self).get_queryset(request)
+        return qs.order_by('-loan_date_end')
+
+    def has_add_permission(self, request, obj=None):
+        return False
+
+    def has_delete_permission(self, request, obj=None):
+        return False
+
+# Avoid to add LoanInline twice in case the file is loaded more than
+# once.
+if LoanInline not in MemberAdmin.inlines:
+    MemberAdmin.inlines.append(LoanInline)

+ 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'),
+        ),
+    ]

+ 23 - 5
hardware_provisioning/models.py

@@ -54,6 +54,8 @@ class Item(models.Model):
         related_name='items',
         null=True, blank=True,
         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,
                                null=True)
 
@@ -81,15 +83,21 @@ class Item(models.Model):
 
     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.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:
         verbose_name = 'objet'
 
@@ -128,9 +136,19 @@ class Loan(models.Model):
         return 'prêt de {item} à {user}'.format(
             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):
         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:
         verbose_name = 'prêt d’objet'
         verbose_name_plural = 'prêts d’objets'

+ 1 - 1
requirements.txt

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