Browse Source

Level permissions

SimonBoulier 7 years ago
parent
commit
cf176d7dcd

+ 38 - 0
README.md

@@ -330,6 +330,44 @@ If you enable an extra-app after initial installation, make sure to sync databas
 *nb: extra apps are loaded after the builtin apps.*
 
 
+Permissions
+===========
+
+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
 ========
 

+ 24 - 0
TODO.md

@@ -0,0 +1,24 @@
+
+[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] 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
+
+[ ] readme -> dossier doc @JocelynD
+[ ] check migrations @JocelynD
+[ ] relire tout le code @JocelynD
+[ ] faire PR
+
+[ ] notifs au bureau (gros truc)
+
+[ ] plusieurs row dans une RowLevel perm (pas indispensable)
+[ ] place de groupes dans admin
+[ ] générer nom de code row level perm

+ 91 - 48
coin/members/admin.py

@@ -5,7 +5,7 @@ from django.shortcuts import render, get_object_or_404
 from django.contrib import admin
 from django.contrib import messages
 from django.contrib.auth.admin import UserAdmin
-from django.contrib.auth.models import Group
+from django.contrib.auth.models import Group, Permission
 from django.http import HttpResponseRedirect
 from django.conf.urls import url
 from django.db.models.query import QuerySet
@@ -13,7 +13,7 @@ from django.core.urlresolvers import reverse
 from django.utils.html import format_html
 
 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.forms import AdminMemberChangeForm, MemberCreationForm
 from coin.utils import delete_selected
@@ -35,17 +35,46 @@ class MembershipFeeInline(admin.TabularInline):
 class OfferSubscriptionInline(admin.TabularInline):
     model = OfferSubscription
     extra = 0
-    exclude = ('comments',)
-    readonly_fields = ('get_subscription_reference', 'subscription_date', 'resign_date',
-                       'commitment', 'offer')
 
+    writable_fields = ('subscription_date', 'resign_date', 'commitment', 'offer')
+    all_fields = ('get_subscription_reference',) + writable_fields
+
+    def get_fields(self, request, obj=None):
+        if obj:
+            return self.all_fields
+        else:
+            return self.writable_fields
+
+    def get_readonly_fields(self, request, obj=None):
+        # création ou superuser : lecture écriture
+        if not obj or request.user.is_superuser:
+            return ('get_subscription_reference',)
+        # modification : lecture seule seulement
+        else:
+            return self.all_fields
+    
     show_change_link = True
 
-    def has_add_permission(self, request, obj=None):
-        return False
+    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"] = 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):
@@ -62,45 +91,46 @@ class MemberAdmin(UserAdmin):
     form = AdminMemberChangeForm
     add_form = MemberCreationForm
 
-    fieldsets = (
-        ('Adhérent', {'fields': (
-            ('status', 'resign_date'),
-            'type',
-            ('first_name', 'last_name', 'nickname'),
-            'organization_name',
-            'comments',
-            'balance')}),
-        ('Coordonnées', {'fields': (
+    def get_fieldsets(self, request, obj=None):
+        coord_fieldset = ('Coordonnées', {'fields': (
             ('email', 'send_membership_fees_email'),
             ('home_phone_number', 'mobile_phone_number'),
             'address',
-            ('postal_code', 'city', 'country'))}),
-        ('Authentification', {'fields': (
-            ('username', 'password'))}),
-        ('Permissions', {'fields': (
-            ('is_active', 'is_staff', 'is_superuser'))}),
-        (None, {'fields': ('date_last_call_for_membership_fees_email',)})
-    )
-
-    add_fieldsets = (
-        ('Adhérent', {'fields': (
-            'status',
-            'type',
-            ('first_name', 'last_name', 'nickname'),
-            'organization_name',
-            'comments',
-            'balance' # XXX we shouldn't need this, the default value should be used
-        )}),
-        ('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}
 
@@ -108,16 +138,27 @@ class MemberAdmin(UserAdmin):
 
     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 = RowLevelPermission.get_manageable_offers(request.user)
+            return qs.filter(offersubscription__offer__in=offers).distinct()
+
     def get_readonly_fields(self, request, obj=None):
+        readonly_fields = []
         if obj:
             # Remove help_text for readonly field (can't do that in the Form
             # django seems to user help_text from model for readonly fields)
             username_field = [
                 f for f in obj._meta.fields if f.name == 'username']
             username_field[0].help_text = ''
-            return ['username', ]
-        else:
-            return []
+
+            readonly_fields.append('username')
+        if not request.user.is_superuser:
+            readonly_fields += ['is_active', 'is_staff', 'is_superuser', 'groups', 'date_last_call_for_membership_fees_email']
+        return readonly_fields
 
     def set_as_member(self, request, queryset):
         rows_updated = queryset.update(status='member')
@@ -215,7 +256,9 @@ class MembershipFeeAdmin(admin.ModelAdmin):
                     'payment_date')
     form = autocomplete_light.modelform_factory(MembershipFee, fields='__all__')
 
+
 admin.site.register(Member, MemberAdmin)
 admin.site.register(MembershipFee, MembershipFeeAdmin)
-admin.site.unregister(Group)
+# admin.site.unregister(Group)
 # admin.site.register(LdapUser, LdapUserAdmin)
+admin.site.register(RowLevelPermission)

+ 5 - 5
coin/members/autocomplete_light_registry.py

@@ -8,13 +8,13 @@ from models import Member
 autocomplete_light.register(Member,
                             # Just like in ModelAdmin.search_fields
                             search_fields=[
-                                '^first_name', 'last_name', 'organization_name',
-                                'username', 'nickname'],
+                                '^first_name', '^last_name', 'organization_name',
+                                '^username', '^nickname'],
                             attrs={
                                 # This will set the input placeholder attribute:
-                                'placeholder': 'Nom/Prénom/Pseudo (min 4 caractères)',
+                                'placeholder': 'Nom/Prénom/Pseudo (min 3 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,
+                                # 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,
                             },
 )

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

+ 40 - 2
coin/members/models.py

@@ -9,14 +9,15 @@ from django.db import models
 from django.db.models import Q, Max
 from django.db.models.signals import pre_save
 from django.dispatch import receiver
-from django.contrib.auth.models import AbstractUser
+from django.contrib.auth.models import AbstractUser, Permission
+from django.contrib.contenttypes.models import ContentType
 from django.conf import settings
 from django.core.validators import RegexValidator
 from django.core.exceptions import ValidationError
 from django.utils import timezone
 from ldapdb.models.fields import CharField, IntegerField, ListField
 
-from coin.offers.models import OfferSubscription
+from coin.offers.models import Offer, OfferSubscription
 from coin.mixins import CoinLdapSyncMixin
 from coin import utils
 
@@ -494,6 +495,43 @@ class LdapUser(ldapdb.models.Model):
 # managed = False  # Indique à Django de ne pas intégrer ce model en base
 
 
+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)
+
+    @classmethod
+    def get_manageable_offers(cls, user):
+        """" Renvoie la liste des offres dont l'utilisateur est autorisé à
+        voir les membres et les abonnements dans l'interface d'administration.
+        """
+        # 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 = cls.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 Offer.objects.filter(rowlevelpermission__in=rowperms) # @JocelynD: un petit .distinct() ici non ?
+
+    @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()
+
+    class Meta:
+        verbose_name = 'permission fine'
+        verbose_name_plural = 'permissions fines'
+
+
+
 @receiver(pre_save, sender=Member)
 def define_username(sender, instance, **kwargs):
     """

+ 28 - 1
coin/offers/admin.py

@@ -2,8 +2,10 @@
 from __future__ import unicode_literals
 
 from django.contrib import admin
+from django.db.models import Q
 from polymorphic.admin import PolymorphicChildModelAdmin
 
+from coin.members.models import RowLevelPermission
 from coin.offers.models import Offer, OfferSubscription
 from coin.offers.offersubscription_filter import\
             OfferSubscriptionTerminationFilter,\
@@ -45,7 +47,32 @@ 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)
+
+    # 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 super(OfferSubscriptionAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)
+        else:
+            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:
+            return qs
+        else:
+            offers = RowLevelPermission.get_manageable_offers(request.user)
+            return qs.filter(offer__in=offers)
 
     def get_inline_instances(self, request, obj=None):
         """

+ 2 - 0
hardware_provisioning/admin.py

@@ -69,6 +69,8 @@ class ItemAdmin(admin.ModelAdmin):
     save_as = True
     actions = ['give_back']
 
+    form = autocomplete_light.modelform_factory(Loan, fields='__all__')
+
     def give_back(self, request, queryset):
         for item in queryset.filter(loans__loan_date_end=None):
             item.give_back()