Parcourir la source

Level permissions

SimonBoulier il y a 7 ans
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.*
 *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
 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 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.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 +13,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,17 +35,46 @@ class MembershipFeeInline(admin.TabularInline):
 class OfferSubscriptionInline(admin.TabularInline):
 class OfferSubscriptionInline(admin.TabularInline):
     model = OfferSubscription
     model = OfferSubscription
     extra = 0
     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
     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):
     def has_delete_permission(self, request, obj=None):
-        return False
+        return request.user.is_superuser
 
 
 
 
 class MemberAdmin(UserAdmin):
 class MemberAdmin(UserAdmin):
@@ -62,45 +91,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': (
+    def get_fieldsets(self, request, obj=None):
+        coord_fieldset = ('Coordonnées', {'fields': (
             ('email', 'send_membership_fees_email'),
             ('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',
-            '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}
     radio_fields = {"type": admin.HORIZONTAL}
 
 
@@ -108,16 +138,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 = RowLevelPermission.get_manageable_offers(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')
@@ -215,7 +256,9 @@ class MembershipFeeAdmin(admin.ModelAdmin):
                     'payment_date')
                     'payment_date')
     form = autocomplete_light.modelform_factory(MembershipFee, fields='__all__')
     form = autocomplete_light.modelform_factory(MembershipFee, fields='__all__')
 
 
+
 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)

+ 5 - 5
coin/members/autocomplete_light_registry.py

@@ -8,13 +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'],
+                                '^first_name', '^last_name', 'organization_name',
+                                '^username', '^nickname'],
                             attrs={
                             attrs={
                                 # This will set the input placeholder attribute:
                                 # 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.
                                 # 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 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
+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 ldapdb.models.fields import CharField, IntegerField, ListField
 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.mixins import CoinLdapSyncMixin
 from coin import utils
 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
 # 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)
 @receiver(pre_save, sender=Member)
 def define_username(sender, instance, **kwargs):
 def define_username(sender, instance, **kwargs):
     """
     """

+ 28 - 1
coin/offers/admin.py

@@ -2,8 +2,10 @@
 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.members.models import RowLevelPermission
 from coin.offers.models import Offer, OfferSubscription
 from coin.offers.models import Offer, OfferSubscription
 from coin.offers.offersubscription_filter import\
 from coin.offers.offersubscription_filter import\
             OfferSubscriptionTerminationFilter,\
             OfferSubscriptionTerminationFilter,\
@@ -45,7 +47,32 @@ 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 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):
     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
     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()