1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2024-12-23 07:52:23 +00:00

Merge branch 'rights' into 'master'

Système de droits

See merge request bde/nk20!10
This commit is contained in:
Pierre-antoine Comby 2020-03-22 00:04:55 +01:00
commit 3556672ed9
42 changed files with 1616 additions and 307 deletions

View File

@ -1,14 +1,15 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import viewsets
from rest_framework.filters import SearchFilter
from api.viewsets import ReadProtectedModelViewSet
from .serializers import ActivityTypeSerializer, ActivitySerializer, GuestSerializer
from ..models import ActivityType, Activity, Guest
class ActivityTypeViewSet(viewsets.ModelViewSet):
class ActivityTypeViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `ActivityType` objects, serialize it to JSON with the given serializer,
@ -20,7 +21,7 @@ class ActivityTypeViewSet(viewsets.ModelViewSet):
filterset_fields = ['name', 'can_invite', ]
class ActivityViewSet(viewsets.ModelViewSet):
class ActivityViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Activity` objects, serialize it to JSON with the given serializer,
@ -32,7 +33,7 @@ class ActivityViewSet(viewsets.ModelViewSet):
filterset_fields = ['name', 'description', 'activity_type', ]
class GuestViewSet(viewsets.ModelViewSet):
class GuestViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Guest` objects, serialize it to JSON with the given serializer,

View File

@ -5,12 +5,15 @@ from django.conf.urls import url, include
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import routers, serializers, viewsets
from rest_framework import routers, serializers
from rest_framework.filters import SearchFilter
from rest_framework.viewsets import ReadOnlyModelViewSet
from activity.api.urls import register_activity_urls
from api.viewsets import ReadProtectedModelViewSet
from member.api.urls import register_members_urls
from note.api.urls import register_note_urls
from logs.api.urls import register_logs_urls
from permission.api.urls import register_permission_urls
class UserSerializer(serializers.ModelSerializer):
@ -39,7 +42,7 @@ class ContentTypeSerializer(serializers.ModelSerializer):
fields = '__all__'
class UserViewSet(viewsets.ModelViewSet):
class UserViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
@ -52,7 +55,8 @@ class UserViewSet(viewsets.ModelViewSet):
search_fields = ['$username', '$first_name', '$last_name', ]
class ContentTypeViewSet(viewsets.ReadOnlyModelViewSet):
# This ViewSet is the only one that is accessible from all authenticated users!
class ContentTypeViewSet(ReadOnlyModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
@ -70,6 +74,7 @@ router.register('user', UserViewSet)
register_members_urls(router, 'members')
register_activity_urls(router, 'activity')
register_note_urls(router, 'note')
register_permission_urls(router, 'permission')
register_logs_urls(router, 'logs')
app_name = 'api'

31
apps/api/viewsets.py Normal file
View File

@ -0,0 +1,31 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.contenttypes.models import ContentType
from permission.backends import PermissionBackend
from rest_framework import viewsets
from note_kfet.middlewares import get_current_authenticated_user
class ReadProtectedModelViewSet(viewsets.ModelViewSet):
"""
Protect a ModelViewSet by filtering the objects that the user cannot see.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
user = get_current_authenticated_user()
self.queryset = model.objects.filter(PermissionBackend.filter_queryset(user, model, "view"))
class ReadOnlyProtectedModelViewSet(viewsets.ReadOnlyModelViewSet):
"""
Protect a ReadOnlyModelViewSet by filtering the objects that the user cannot see.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
user = get_current_authenticated_user()
self.queryset = model.objects.filter(PermissionBackend.filter_queryset(user, model, "view"))

View File

@ -2,14 +2,14 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import viewsets
from rest_framework.filters import OrderingFilter
from api.viewsets import ReadOnlyProtectedModelViewSet
from .serializers import ChangelogSerializer
from ..models import Changelog
class ChangelogViewSet(viewsets.ReadOnlyModelViewSet):
class ChangelogViewSet(ReadOnlyProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer,

View File

@ -1,77 +0,0 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from threading import local
USER_ATTR_NAME = getattr(settings, 'LOCAL_USER_ATTR_NAME', '_current_user')
IP_ATTR_NAME = getattr(settings, 'LOCAL_IP_ATTR_NAME', '_current_ip')
_thread_locals = local()
def _set_current_user_and_ip(user=None, ip=None):
"""
Store current user and IP address in the local thread.
"""
setattr(_thread_locals, USER_ATTR_NAME, user)
setattr(_thread_locals, IP_ATTR_NAME, ip)
def get_current_user():
"""
:return: The user that performed a request (may be anonymous)
"""
return getattr(_thread_locals, USER_ATTR_NAME, None)
def get_current_ip():
"""
:return: The IP address of the user that has performed a request
"""
return getattr(_thread_locals, IP_ATTR_NAME, None)
def get_current_authenticated_user():
"""
:return: The user that performed a request (must be authenticated, return None if anonymous)
"""
current_user = get_current_user()
if isinstance(current_user, AnonymousUser):
return None
return current_user
class LogsMiddleware(object):
"""
This middleware gets the current user with his or her IP address on each request.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
"""
This function is called on each request.
:param request: The HTTP Request
:return: The HTTP Response
"""
user = request.user
# Get request IP from the headers
# The `REMOTE_ADDR` field may not contain the true IP, if there is a proxy
if 'HTTP_X_FORWARDED_FOR' in request.META:
ip = request.META.get('HTTP_X_FORWARDED_FOR')
else:
ip = request.META.get('REMOTE_ADDR')
# The user and the IP address are stored in the current thread
_set_current_user_and_ip(user, ip)
# The request is then analysed, and the response is generated
response = self.get_response(request)
# We flush the connected user and the IP address for the next requests
_set_current_user_and_ip(None, None)
return response

View File

@ -4,14 +4,13 @@
from django.contrib.contenttypes.models import ContentType
from rest_framework.renderers import JSONRenderer
from rest_framework.serializers import ModelSerializer
from note.models import NoteUser, Alias
from note_kfet.middlewares import get_current_authenticated_user, get_current_ip
from .models import Changelog
import getpass
from note.models import NoteUser, Alias
from .middlewares import get_current_authenticated_user, get_current_ip
from .models import Changelog
# Ces modèles ne nécessitent pas de logs
EXCLUDED = [

View File

@ -15,6 +15,7 @@ class ProfileSerializer(serializers.ModelSerializer):
class Meta:
model = Profile
fields = '__all__'
read_only_fields = ('user', )
class ClubSerializer(serializers.ModelSerializer):

View File

@ -1,14 +1,14 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from rest_framework import viewsets
from rest_framework.filters import SearchFilter
from api.viewsets import ReadProtectedModelViewSet
from .serializers import ProfileSerializer, ClubSerializer, RoleSerializer, MembershipSerializer
from ..models import Profile, Club, Role, Membership
class ProfileViewSet(viewsets.ModelViewSet):
class ProfileViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Profile` objects, serialize it to JSON with the given serializer,
@ -18,7 +18,7 @@ class ProfileViewSet(viewsets.ModelViewSet):
serializer_class = ProfileSerializer
class ClubViewSet(viewsets.ModelViewSet):
class ClubViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Club` objects, serialize it to JSON with the given serializer,
@ -30,7 +30,7 @@ class ClubViewSet(viewsets.ModelViewSet):
search_fields = ['$name', ]
class RoleViewSet(viewsets.ModelViewSet):
class RoleViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Role` objects, serialize it to JSON with the given serializer,
@ -42,7 +42,7 @@ class RoleViewSet(viewsets.ModelViewSet):
search_fields = ['$name', ]
class MembershipViewSet(viewsets.ModelViewSet):
class MembershipViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Membership` objects, serialize it to JSON with the given serializer,

View File

@ -6,12 +6,21 @@ from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout
from dal import autocomplete
from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
from django.contrib.auth.models import User
from permission.models import PermissionMask
from .models import Profile, Club, Membership
class CustomAuthenticationForm(AuthenticationForm):
permission_mask = forms.ModelChoiceField(
label="Masque de permissions",
queryset=PermissionMask.objects.order_by("rank"),
empty_label=None,
)
class SignUpForm(UserCreationForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@ -1,6 +1,8 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import datetime
from django.conf import settings
from django.db import models
from django.urls import reverse, reverse_lazy
@ -150,16 +152,13 @@ class Membership(models.Model):
verbose_name=_('fee'),
)
def valid(self):
if self.date_end is not None:
return self.date_start.toordinal() <= datetime.datetime.now().toordinal() < self.date_end.toordinal()
else:
return self.date_start.toordinal() <= datetime.datetime.now().toordinal()
class Meta:
verbose_name = _('membership')
verbose_name_plural = _('memberships')
indexes = [models.Index(fields=['user'])]
# @receiver(post_save, sender=settings.AUTH_USER_MODEL)
# def save_user_profile(instance, created, **_kwargs):
# """
# Hook to save an user profile when an user is updated
# """
# if created:
# Profile.objects.create(user=instance)
# instance.profile.save()

View File

@ -9,6 +9,7 @@ from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import User
from django.contrib.auth.views import LoginView
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.http import HttpResponseRedirect
@ -23,13 +24,23 @@ from note.forms import AliasForm, ImageForm
from note.models import Alias, NoteUser
from note.models.transactions import Transaction
from note.tables import HistoryTable, AliasTable
from permission.backends import PermissionBackend
from .filters import UserFilter, UserFilterFormHelper
from .forms import SignUpForm, ProfileForm, ClubForm, MembershipForm, MemberFormSet, FormSetHelper
from .forms import SignUpForm, ProfileForm, ClubForm, MembershipForm, MemberFormSet, FormSetHelper, \
CustomAuthenticationForm
from .models import Club, Membership
from .tables import ClubTable, UserTable
class CustomLoginView(LoginView):
form_class = CustomAuthenticationForm
def form_valid(self, form):
self.request.session['permission_mask'] = form.cleaned_data['permission_mask'].rank
return super().form_valid(form)
class UserCreateView(CreateView):
"""
Une vue pour inscrire un utilisateur et lui créer un profile
@ -120,6 +131,9 @@ class UserDetailView(LoginRequiredMixin, DetailView):
context_object_name = "user_object"
template_name = "member/profile_detail.html"
def get_queryset(self, **kwargs):
return super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, User, "view"))
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user = context['user_object']
@ -147,7 +161,7 @@ class UserListView(LoginRequiredMixin, SingleTableView):
formhelper_class = UserFilterFormHelper
def get_queryset(self, **kwargs):
qs = super().get_queryset()
qs = super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, User, "view"))
self.filter = self.filter_class(self.request.GET, queryset=qs)
self.filter.form.helper = self.formhelper_class()
return self.filter.qs
@ -203,7 +217,6 @@ class DeleteAliasView(LoginRequiredMixin, DeleteView):
return HttpResponseRedirect(self.get_success_url())
def get_success_url(self):
print(self.request)
return reverse_lazy('member:user_alias', kwargs={'pk': self.object.note.user.pk})
def get(self, request, *args, **kwargs):
@ -297,7 +310,7 @@ class UserAutocomplete(autocomplete.Select2QuerySetView):
if not self.request.user.is_authenticated:
return User.objects.none()
qs = User.objects.all()
qs = User.objects.filter(PermissionBackend.filter_queryset(self.request.user, User, "view")).all()
if self.q:
qs = qs.filter(username__regex="^" + self.q)
@ -328,11 +341,17 @@ class ClubListView(LoginRequiredMixin, SingleTableView):
model = Club
table_class = ClubTable
def get_queryset(self, **kwargs):
return super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, Club, "view"))
class ClubDetailView(LoginRequiredMixin, DetailView):
model = Club
context_object_name = "club"
def get_queryset(self, **kwargs):
return super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, Club, "view"))
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
club = context["club"]
@ -351,6 +370,11 @@ class ClubAddMemberView(LoginRequiredMixin, CreateView):
form_class = MembershipForm
template_name = 'member/add_members.html'
def get_queryset(self, **kwargs):
return super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, Membership, "view")
| PermissionBackend.filter_queryset(self.request.user, Membership,
"change"))
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['formset'] = MemberFormSet()

View File

@ -8,7 +8,7 @@ from polymorphic.admin import PolymorphicChildModelAdmin, \
from .models.notes import Alias, Note, NoteClub, NoteSpecial, NoteUser
from .models.transactions import Transaction, TemplateCategory, TransactionTemplate, \
TemplateTransaction, MembershipTransaction
RecurrentTransaction, MembershipTransaction
class AliasInlines(admin.TabularInline):
@ -102,7 +102,7 @@ class TransactionAdmin(PolymorphicParentModelAdmin):
"""
Admin customisation for Transaction
"""
child_models = (TemplateTransaction, MembershipTransaction)
child_models = (RecurrentTransaction, MembershipTransaction)
list_display = ('created_at', 'poly_source', 'poly_destination',
'quantity', 'amount', 'valid')
list_filter = ('valid',)

View File

@ -6,7 +6,7 @@ from rest_polymorphic.serializers import PolymorphicSerializer
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias
from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory, \
TemplateTransaction, SpecialTransaction
RecurrentTransaction, SpecialTransaction
class NoteSerializer(serializers.ModelSerializer):
@ -18,6 +18,7 @@ class NoteSerializer(serializers.ModelSerializer):
class Meta:
model = Note
fields = '__all__'
read_only_fields = [f.name for f in model._meta.get_fields()] # Notes are read-only protected
class NoteClubSerializer(serializers.ModelSerializer):
@ -30,6 +31,7 @@ class NoteClubSerializer(serializers.ModelSerializer):
class Meta:
model = NoteClub
fields = '__all__'
read_only_fields = ('note', 'club', )
def get_name(self, obj):
return str(obj)
@ -45,6 +47,7 @@ class NoteSpecialSerializer(serializers.ModelSerializer):
class Meta:
model = NoteSpecial
fields = '__all__'
read_only_fields = ('note', )
def get_name(self, obj):
return str(obj)
@ -60,6 +63,7 @@ class NoteUserSerializer(serializers.ModelSerializer):
class Meta:
model = NoteUser
fields = '__all__'
read_only_fields = ('note', 'user', )
def get_name(self, obj):
return str(obj)
@ -70,14 +74,11 @@ class AliasSerializer(serializers.ModelSerializer):
REST API Serializer for Aliases.
The djangorestframework plugin will analyse the model `Alias` and parse all fields in the API.
"""
note = serializers.SerializerMethodField()
class Meta:
model = Alias
fields = '__all__'
def get_note(self, alias):
return NotePolymorphicSerializer().to_representation(alias.note)
read_only_fields = ('note', )
class NotePolymorphicSerializer(PolymorphicSerializer):
@ -88,6 +89,9 @@ class NotePolymorphicSerializer(PolymorphicSerializer):
NoteSpecial: NoteSpecialSerializer
}
class Meta:
model = Note
class TemplateCategorySerializer(serializers.ModelSerializer):
"""
@ -122,14 +126,14 @@ class TransactionSerializer(serializers.ModelSerializer):
fields = '__all__'
class TemplateTransactionSerializer(serializers.ModelSerializer):
class RecurrentTransactionSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Transactions.
The djangorestframework plugin will analyse the model `TemplateTransaction` and parse all fields in the API.
The djangorestframework plugin will analyse the model `RecurrentTransaction` and parse all fields in the API.
"""
class Meta:
model = TemplateTransaction
model = RecurrentTransaction
fields = '__all__'
@ -158,7 +162,10 @@ class SpecialTransactionSerializer(serializers.ModelSerializer):
class TransactionPolymorphicSerializer(PolymorphicSerializer):
model_serializer_mapping = {
Transaction: TransactionSerializer,
TemplateTransaction: TemplateTransactionSerializer,
RecurrentTransaction: RecurrentTransactionSerializer,
MembershipTransaction: MembershipTransactionSerializer,
SpecialTransaction: SpecialTransactionSerializer,
}
class Meta:
model = Transaction

View File

@ -3,57 +3,16 @@
from django.db.models import Q
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import viewsets
from rest_framework.filters import OrderingFilter, SearchFilter
from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet
from .serializers import NoteSerializer, NotePolymorphicSerializer, NoteClubSerializer, NoteSpecialSerializer, \
NoteUserSerializer, AliasSerializer, \
TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias
from .serializers import NotePolymorphicSerializer, AliasSerializer, TemplateCategorySerializer, \
TransactionTemplateSerializer, TransactionPolymorphicSerializer
from ..models.notes import Note, Alias
from ..models.transactions import TransactionTemplate, Transaction, TemplateCategory
class NoteViewSet(viewsets.ModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Note` objects, serialize it to JSON with the given serializer,
then render it on /api/note/note/
"""
queryset = Note.objects.all()
serializer_class = NoteSerializer
class NoteClubViewSet(viewsets.ModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `NoteClub` objects, serialize it to JSON with the given serializer,
then render it on /api/note/club/
"""
queryset = NoteClub.objects.all()
serializer_class = NoteClubSerializer
class NoteSpecialViewSet(viewsets.ModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `NoteSpecial` objects, serialize it to JSON with the given serializer,
then render it on /api/note/special/
"""
queryset = NoteSpecial.objects.all()
serializer_class = NoteSpecialSerializer
class NoteUserViewSet(viewsets.ModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `NoteUser` objects, serialize it to JSON with the given serializer,
then render it on /api/note/user/
"""
queryset = NoteUser.objects.all()
serializer_class = NoteUserSerializer
class NotePolymorphicViewSet(viewsets.ModelViewSet):
class NotePolymorphicViewSet(ReadOnlyProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Note` objects (with polymorhism), serialize it to JSON with the given serializer,
@ -70,29 +29,18 @@ class NotePolymorphicViewSet(viewsets.ModelViewSet):
Parse query and apply filters.
:return: The filtered set of requested notes
"""
queryset = Note.objects.all()
queryset = super().get_queryset()
alias = self.request.query_params.get("alias", ".*")
queryset = queryset.filter(
Q(alias__name__regex="^" + alias)
| Q(alias__normalized_name__regex="^" + Alias.normalize(alias))
| Q(alias__normalized_name__regex="^" + alias.lower()))
note_type = self.request.query_params.get("type", None)
if note_type:
types = str(note_type).lower()
if "user" in types:
queryset = queryset.filter(polymorphic_ctype__model="noteuser")
elif "club" in types:
queryset = queryset.filter(polymorphic_ctype__model="noteclub")
elif "special" in types:
queryset = queryset.filter(polymorphic_ctype__model="notespecial")
else:
queryset = queryset.none()
return queryset.distinct()
class AliasViewSet(viewsets.ModelViewSet):
class AliasViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Alias` objects, serialize it to JSON with the given serializer,
@ -110,35 +58,18 @@ class AliasViewSet(viewsets.ModelViewSet):
:return: The filtered set of requested aliases
"""
queryset = Alias.objects.all()
queryset = super().get_queryset()
alias = self.request.query_params.get("alias", ".*")
queryset = queryset.filter(
Q(name__regex="^" + alias) | Q(normalized_name__regex="^" + alias.lower()))
note_id = self.request.query_params.get("note", None)
if note_id:
queryset = queryset.filter(id=note_id)
note_type = self.request.query_params.get("type", None)
if note_type:
types = str(note_type).lower()
if "user" in types:
queryset = queryset.filter(
note__polymorphic_ctype__model="noteuser")
elif "club" in types:
queryset = queryset.filter(
note__polymorphic_ctype__model="noteclub")
elif "special" in types:
queryset = queryset.filter(
note__polymorphic_ctype__model="notespecial")
else:
queryset = queryset.none()
Q(name__regex="^" + alias)
| Q(normalized_name__regex="^" + Alias.normalize(alias))
| Q(normalized_name__regex="^" + alias.lower()))
return queryset
class TemplateCategoryViewSet(viewsets.ModelViewSet):
class TemplateCategoryViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `TemplateCategory` objects, serialize it to JSON with the given serializer,
@ -150,7 +81,7 @@ class TemplateCategoryViewSet(viewsets.ModelViewSet):
search_fields = ['$name', ]
class TransactionTemplateViewSet(viewsets.ModelViewSet):
class TransactionTemplateViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `TransactionTemplate` objects, serialize it to JSON with the given serializer,
@ -162,7 +93,7 @@ class TransactionTemplateViewSet(viewsets.ModelViewSet):
filterset_fields = ['name', 'amount', 'display', 'category', ]
class TransactionViewSet(viewsets.ModelViewSet):
class TransactionViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Transaction` objects, serialize it to JSON with the given serializer,

View File

@ -3,12 +3,12 @@
from .notes import Alias, Note, NoteClub, NoteSpecial, NoteUser
from .transactions import MembershipTransaction, Transaction, \
TemplateCategory, TransactionTemplate, TemplateTransaction
TemplateCategory, TransactionTemplate, RecurrentTransaction
__all__ = [
# Notes
'Alias', 'Note', 'NoteClub', 'NoteSpecial', 'NoteUser',
# Transactions
'MembershipTransaction', 'Transaction', 'TemplateCategory', 'TransactionTemplate',
'TemplateTransaction',
'RecurrentTransaction',
]

View File

@ -152,10 +152,12 @@ class Transaction(PolymorphicModel):
self.source.balance -= to_transfer
self.destination.balance += to_transfer
# We save first the transaction, in case of the user has no right to transfer money
super().save(*args, **kwargs)
# Save notes
self.source.save()
self.destination.save()
super().save(*args, **kwargs)
@property
def total(self):
@ -166,7 +168,7 @@ class Transaction(PolymorphicModel):
return _('Transfer')
class TemplateTransaction(Transaction):
class RecurrentTransaction(Transaction):
"""
Special type of :model:`note.Transaction` associated to a :model:`note.TransactionTemplate`.
"""

View File

@ -8,9 +8,10 @@ from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, ListView, UpdateView
from django_tables2 import SingleTableView
from permission.backends import PermissionBackend
from .forms import TransactionTemplateForm
from .models import Transaction, TransactionTemplate, Alias, TemplateTransaction, NoteSpecial
from .models import Transaction, TransactionTemplate, Alias, RecurrentTransaction, NoteSpecial
from .models.transactions import SpecialTransaction
from .tables import HistoryTable
@ -18,16 +19,18 @@ from .tables import HistoryTable
class TransactionCreate(LoginRequiredMixin, SingleTableView):
"""
Show transfer page
TODO: If user have sufficient rights, they can transfer from an other note
"""
queryset = Transaction.objects.order_by("-id").all()[:50]
template_name = "note/transaction_form.html"
# Transaction history table
table_class = HistoryTable
table_pagination = {"per_page": 50}
def get_queryset(self):
return Transaction.objects.filter(PermissionBackend.filter_queryset(
self.request.user, Transaction, "view")
).order_by("-id").all()[:50]
def get_context_data(self, **kwargs):
"""
Add some context variables in template such as page title
@ -117,25 +120,30 @@ class ConsoView(LoginRequiredMixin, SingleTableView):
"""
Consume
"""
queryset = Transaction.objects.order_by("-id").all()[:50]
template_name = "note/conso_form.html"
# Transaction history table
table_class = HistoryTable
table_pagination = {"per_page": 50}
def get_queryset(self):
return Transaction.objects.filter(
PermissionBackend.filter_queryset(self.request.user, Transaction, "view")
).order_by("-id").all()[:50]
def get_context_data(self, **kwargs):
"""
Add some context variables in template such as page title
"""
context = super().get_context_data(**kwargs)
from django.db.models import Count
buttons = TransactionTemplate.objects.filter(display=True) \
.annotate(clicks=Count('templatetransaction')).order_by('category__name', 'name')
buttons = TransactionTemplate.objects.filter(
PermissionBackend().filter_queryset(self.request.user, TransactionTemplate, "view")
).filter(display=True).annotate(clicks=Count('recurrenttransaction')).order_by('category__name', 'name')
context['transaction_templates'] = buttons
context['most_used'] = buttons.order_by('-clicks', 'name')[:10]
context['title'] = _("Consumptions")
context['polymorphic_ctype'] = ContentType.objects.get_for_model(TemplateTransaction).pk
context['polymorphic_ctype'] = ContentType.objects.get_for_model(RecurrentTransaction).pk
# select2 compatibility
context['no_cache'] = True

View File

@ -0,0 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = 'permission.apps.PermissionConfig'

31
apps/permission/admin.py Normal file
View File

@ -0,0 +1,31 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-lateré
from django.contrib import admin
from .models import Permission, PermissionMask, RolePermissions
@admin.register(PermissionMask)
class PermissionMaskAdmin(admin.ModelAdmin):
"""
Admin customisation for PermissionMask
"""
list_display = ('description', 'rank', )
@admin.register(Permission)
class PermissionAdmin(admin.ModelAdmin):
"""
Admin customisation for Permission
"""
list_display = ('type', 'model', 'field', 'mask', 'description', )
@admin.register(RolePermissions)
class RolePermissionsAdmin(admin.ModelAdmin):
"""
Admin customisation for RolePermissions
"""
list_display = ('role', )

View File

View File

@ -0,0 +1,17 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from rest_framework import serializers
from ..models import Permission
class PermissionSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Permission types.
The djangorestframework plugin will analyse the model `Permission` and parse all fields in the API.
"""
class Meta:
model = Permission
fields = '__all__'

View File

@ -0,0 +1,11 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from .views import PermissionViewSet
def register_permission_urls(router, path):
"""
Configure router for permission REST API.
"""
router.register(path, PermissionViewSet)

View File

@ -0,0 +1,20 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django_filters.rest_framework import DjangoFilterBackend
from api.viewsets import ReadOnlyProtectedModelViewSet
from .serializers import PermissionSerializer
from ..models import Permission
class PermissionViewSet(ReadOnlyProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer,
then render it on /api/logs/
"""
queryset = Permission.objects.all()
serializer_class = PermissionSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['model', 'type', ]

14
apps/permission/apps.py Normal file
View File

@ -0,0 +1,14 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
from django.db.models.signals import pre_save, pre_delete
class PermissionConfig(AppConfig):
name = 'permission'
def ready(self):
from . import signals
pre_save.connect(signals.pre_save_object)
pre_delete.connect(signals.pre_delete_object)

116
apps/permission/backends.py Normal file
View File

@ -0,0 +1,116 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import User, AnonymousUser
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q, F
from note.models import Note, NoteUser, NoteClub, NoteSpecial
from note_kfet.middlewares import get_current_session
from member.models import Membership, Club
from .models import Permission
class PermissionBackend(ModelBackend):
"""
Manage permissions of users
"""
supports_object_permissions = True
supports_anonymous_user = False
supports_inactive_user = False
@staticmethod
def permissions(user, model, type):
"""
List all permissions of the given user that applies to a given model and a give type
:param user: The owner of the permissions
:param model: The model that the permissions shoud apply
:param type: The type of the permissions: view, change, add or delete
:return: A generator of the requested permissions
"""
for permission in Permission.objects.annotate(club=F("rolepermissions__role__membership__club")) \
.filter(
rolepermissions__role__membership__user=user,
model__app_label=model.app_label, # For polymorphic models, we don't filter on model type
type=type,
).all():
if not isinstance(model, permission.model.__class__):
continue
club = Club.objects.get(pk=permission.club)
permission = permission.about(
user=user,
club=club,
User=User,
Club=Club,
Membership=Membership,
Note=Note,
NoteUser=NoteUser,
NoteClub=NoteClub,
NoteSpecial=NoteSpecial,
F=F,
Q=Q
)
if permission.mask.rank <= get_current_session().get("permission_mask", 0):
yield permission
@staticmethod
def filter_queryset(user, model, t, field=None):
"""
Filter a queryset by considering the permissions of a given user.
:param user: The owner of the permissions that are fetched
:param model: The concerned model of the queryset
:param t: The type of modification (view, add, change, delete)
:param field: The field of the model to test, if concerned
:return: A query that corresponds to the filter to give to a queryset
"""
if user is None or isinstance(user, AnonymousUser):
# Anonymous users can't do anything
return Q(pk=-1)
if user.is_superuser and get_current_session().get("permission_mask", 0) >= 42:
# Superusers have all rights
return Q()
if not isinstance(model, ContentType):
model = ContentType.objects.get_for_model(model)
# Never satisfied
query = Q(pk=-1)
perms = PermissionBackend.permissions(user, model, t)
for perm in perms:
if perm.field and field != perm.field:
continue
if perm.type != t or perm.model != model:
continue
perm.update_query()
query = query | perm.query
return query
def has_perm(self, user_obj, perm, obj=None):
if user_obj is None or isinstance(user_obj, AnonymousUser):
return False
if user_obj.is_superuser and get_current_session().get("permission_mask", 0) >= 42:
return True
if obj is None:
return True
perm = perm.split('.')[-1].split('_', 2)
perm_type = perm[0]
perm_field = perm[2] if len(perm) == 3 else None
ct = ContentType.objects.get_for_model(obj)
if any(permission.applies(obj, perm_type, perm_field)
for permission in self.permissions(user_obj, ct, perm_type)):
return True
return False
def has_module_perms(self, user_obj, app_label):
return False
def get_all_permissions(self, user_obj, obj=None):
ct = ContentType.objects.get_for_model(obj)
return list(self.permissions(user_obj, ct, "view"))

View File

@ -0,0 +1,554 @@
[
{
"model": "member.role",
"pk": 1,
"fields": {
"name": "Adh\u00e9rent BDE"
}
},
{
"model": "member.role",
"pk": 2,
"fields": {
"name": "Adh\u00e9rent Kfet"
}
},
{
"model": "member.role",
"pk": 3,
"fields": {
"name": "Pr\u00e9sident\u00b7e BDE"
}
},
{
"model": "member.role",
"pk": 4,
"fields": {
"name": "Tr\u00e9sorier\u00b7\u00e8re BDE"
}
},
{
"model": "member.role",
"pk": 5,
"fields": {
"name": "Respo info"
}
},
{
"model": "member.role",
"pk": 6,
"fields": {
"name": "GC Kfet"
}
},
{
"model": "member.role",
"pk": 7,
"fields": {
"name": "Pr\u00e9sident\u00b7e de club"
}
},
{
"model": "member.role",
"pk": 8,
"fields": {
"name": "Tr\u00e9sorier\u00b7\u00e8re de club"
}
},
{
"model": "permission.permissionmask",
"pk": 1,
"fields": {
"rank": 0,
"description": "Droits basiques"
}
},
{
"model": "permission.permissionmask",
"pk": 2,
"fields": {
"rank": 1,
"description": "Droits note seulement"
}
},
{
"model": "permission.permissionmask",
"pk": 3,
"fields": {
"rank": 42,
"description": "Tous mes droits"
}
},
{
"model": "permission.permission",
"pk": 1,
"fields": {
"model": 21,
"query": "{\"pk\": [\"user\", \"pk\"]}",
"type": "view",
"mask": 1,
"field": "",
"description": "View our User object"
}
},
{
"model": "permission.permission",
"pk": 2,
"fields": {
"model": 31,
"query": "{\"user\": [\"user\"]}",
"type": "view",
"mask": 1,
"field": "",
"description": "View our profile"
}
},
{
"model": "permission.permission",
"pk": 3,
"fields": {
"model": 34,
"query": "{\"pk\": [\"user\", \"note\", \"pk\"]}",
"type": "view",
"mask": 1,
"field": "",
"description": "View our own note"
}
},
{
"model": "permission.permission",
"pk": 4,
"fields": {
"model": 25,
"query": "{\"user\": [\"user\"]}",
"type": "view",
"mask": 1,
"field": "",
"description": "View our API token"
}
},
{
"model": "permission.permission",
"pk": 5,
"fields": {
"model": 36,
"query": "[\"OR\", {\"source\": [\"user\", \"note\"]}, {\"destination\": [\"user\", \"note\"]}]",
"type": "view",
"mask": 1,
"field": "",
"description": "View our own transactions"
}
},
{
"model": "permission.permission",
"pk": 6,
"fields": {
"model": 33,
"query": "[\"OR\", {\"note__in\": [\"NoteUser\", \"objects\", [\"filter\", {\"user__membership__club__name\": \"Kfet\"}], [\"all\"]]}, {\"note__in\": [\"NoteClub\", \"objects\", [\"all\"]]}]",
"type": "view",
"mask": 1,
"field": "",
"description": "View aliases of clubs and members of Kfet club"
}
},
{
"model": "permission.permission",
"pk": 7,
"fields": {
"model": 21,
"query": "{\"pk\": [\"user\", \"pk\"]}",
"type": "change",
"mask": 1,
"field": "last_login",
"description": "Change myself's last login"
}
},
{
"model": "permission.permission",
"pk": 8,
"fields": {
"model": 21,
"query": "{\"pk\": [\"user\", \"pk\"]}",
"type": "change",
"mask": 1,
"field": "username",
"description": "Change myself's username"
}
},
{
"model": "permission.permission",
"pk": 9,
"fields": {
"model": 21,
"query": "{\"pk\": [\"user\", \"pk\"]}",
"type": "change",
"mask": 1,
"field": "first_name",
"description": "Change myself's first name"
}
},
{
"model": "permission.permission",
"pk": 10,
"fields": {
"model": 21,
"query": "{\"pk\": [\"user\", \"pk\"]}",
"type": "change",
"mask": 1,
"field": "last_name",
"description": "Change myself's last name"
}
},
{
"model": "permission.permission",
"pk": 11,
"fields": {
"model": 21,
"query": "{\"pk\": [\"user\", \"pk\"]}",
"type": "change",
"mask": 1,
"field": "email",
"description": "Change myself's email"
}
},
{
"model": "permission.permission",
"pk": 12,
"fields": {
"model": 25,
"query": "{\"user\": [\"user\"]}",
"type": "delete",
"mask": 1,
"field": "",
"description": "Delete API Token"
}
},
{
"model": "permission.permission",
"pk": 13,
"fields": {
"model": 25,
"query": "{\"user\": [\"user\"]}",
"type": "add",
"mask": 1,
"field": "",
"description": "Create API Token"
}
},
{
"model": "permission.permission",
"pk": 14,
"fields": {
"model": 33,
"query": "{\"note\": [\"user\", \"note\"]}",
"type": "delete",
"mask": 1,
"field": "",
"description": "Remove alias"
}
},
{
"model": "permission.permission",
"pk": 15,
"fields": {
"model": 33,
"query": "{\"note\": [\"user\", \"note\"]}",
"type": "add",
"mask": 1,
"field": "",
"description": "Add alias"
}
},
{
"model": "permission.permission",
"pk": 16,
"fields": {
"model": 34,
"query": "{\"pk\": [\"user\", \"note\", \"pk\"]}",
"type": "change",
"mask": 1,
"field": "display_image",
"description": "Change myself's display image"
}
},
{
"model": "permission.permission",
"pk": 17,
"fields": {
"model": 36,
"query": "[\"AND\", {\"source\": [\"user\", \"note\"]}, {\"amount__lte\": [\"user\", \"note\", \"balance\"]}]",
"type": "add",
"mask": 1,
"field": "",
"description": "Transfer from myself's note"
}
},
{
"model": "permission.permission",
"pk": 18,
"fields": {
"model": 34,
"query": "{}",
"type": "change",
"mask": 1,
"field": "balance",
"description": "Update a note balance with a transaction"
}
},
{
"model": "permission.permission",
"pk": 19,
"fields": {
"model": 34,
"query": "[\"OR\", {\"pk\": [\"club\", \"note\", \"pk\"]}, {\"pk__in\": [\"NoteUser\", \"objects\", [\"filter\", {\"user__membership__club\": [\"club\"]}], [\"all\"]]}]",
"type": "view",
"mask": 2,
"field": "",
"description": "View notes of club members"
}
},
{
"model": "permission.permission",
"pk": 20,
"fields": {
"model": 36,
"query": "[\"AND\", [\"OR\", {\"source\": [\"club\", \"note\"]}, {\"destination\": [\"club\", \"note\"]}], {\"amount__lte\": {\"F\": [\"ADD\", [\"F\", \"source__balance\"], 5000]}}]",
"type": "add",
"mask": 2,
"field": "",
"description": "Create transactions with a club"
}
},
{
"model": "permission.permission",
"pk": 21,
"fields": {
"model": 42,
"query": "[\"AND\", {\"destination\": [\"club\", \"note\"]}, {\"amount__lte\": {\"F\": [\"ADD\", [\"F\", \"source__balance\"], 5000]}}]",
"type": "add",
"mask": 2,
"field": "",
"description": "Create transactions from buttons with a club"
}
},
{
"model": "permission.permission",
"pk": 22,
"fields": {
"model": 29,
"query": "{\"pk\": [\"club\", \"pk\"]}",
"type": "view",
"mask": 1,
"field": "",
"description": "View club infos"
}
},
{
"model": "permission.permission",
"pk": 23,
"fields": {
"model": 36,
"query": "{}",
"type": "change",
"mask": 1,
"field": "valid",
"description": "Update validation status of a transaction"
}
},
{
"model": "permission.permission",
"pk": 24,
"fields": {
"model": 36,
"query": "{}",
"type": "view",
"mask": 2,
"field": "",
"description": "View all transactions"
}
},
{
"model": "permission.permission",
"pk": 25,
"fields": {
"model": 40,
"query": "{}",
"type": "view",
"mask": 2,
"field": "",
"description": "Display credit/debit interface"
}
},
{
"model": "permission.permission",
"pk": 26,
"fields": {
"model": 43,
"query": "{}",
"type": "add",
"mask": 2,
"field": "",
"description": "Create credit/debit transaction"
}
},
{
"model": "permission.permission",
"pk": 27,
"fields": {
"model": 35,
"query": "{}",
"type": "view",
"mask": 2,
"field": "",
"description": "View button categories"
}
},
{
"model": "permission.permission",
"pk": 28,
"fields": {
"model": 35,
"query": "{}",
"type": "change",
"mask": 3,
"field": "",
"description": "Change button category"
}
},
{
"model": "permission.permission",
"pk": 29,
"fields": {
"model": 35,
"query": "{}",
"type": "add",
"mask": 3,
"field": "",
"description": "Add button category"
}
},
{
"model": "permission.permission",
"pk": 30,
"fields": {
"model": 37,
"query": "{}",
"type": "view",
"mask": 2,
"field": "",
"description": "View buttons"
}
},
{
"model": "permission.permission",
"pk": 31,
"fields": {
"model": 37,
"query": "{}",
"type": "add",
"mask": 3,
"field": "",
"description": "Add buttons"
}
},
{
"model": "permission.permission",
"pk": 32,
"fields": {
"model": 37,
"query": "{}",
"type": "change",
"mask": 3,
"field": "",
"description": "Update buttons"
}
},
{
"model": "permission.permission",
"pk": 33,
"fields": {
"model": 36,
"query": "{}",
"type": "add",
"mask": 2,
"field": "",
"description": "Create any transaction"
}
},
{
"model": "permission.rolepermissions",
"pk": 1,
"fields": {
"role": 1,
"permissions": [
1,
2,
7,
8,
9,
10,
11
]
}
},
{
"model": "permission.rolepermissions",
"pk": 2,
"fields": {
"role": 2,
"permissions": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18
]
}
},
{
"model": "permission.rolepermissions",
"pk": 3,
"fields": {
"role": 8,
"permissions": [
19,
20,
21,
22
]
}
},
{
"model": "permission.rolepermissions",
"pk": 4,
"fields": {
"role": 4,
"permissions": [
23,
24,
25,
26,
27,
28,
29,
30,
31,
32,
33
]
}
}
]

View File

284
apps/permission/models.py Normal file
View File

@ -0,0 +1,284 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import functools
import json
import operator
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Q, Model
from django.utils.translation import gettext_lazy as _
from member.models import Role
class InstancedPermission:
def __init__(self, model, query, type, field, mask, **kwargs):
self.model = model
self.raw_query = query
self.query = None
self.type = type
self.field = field
self.mask = mask
self.kwargs = kwargs
def applies(self, obj, permission_type, field_name=None):
"""
Returns True if the permission applies to
the field `field_name` object `obj`
"""
if not isinstance(obj, self.model.model_class()):
# The permission does not apply to the model
return False
if self.type == 'add':
if permission_type == self.type:
self.update_query()
# Don't increase indexes
obj.pk = 0
# Force insertion, no data verification, no trigger
Model.save(obj, force_insert=True)
ret = obj in self.model.model_class().objects.filter(self.query).all()
# Delete testing object
Model.delete(obj)
return ret
if permission_type == self.type:
if self.field and field_name != self.field:
return False
self.update_query()
return obj in self.model.model_class().objects.filter(self.query).all()
else:
return False
def update_query(self):
"""
The query is not analysed in a first time. It is analysed at most once if needed.
:return:
"""
if not self.query:
# noinspection PyProtectedMember
self.query = Permission._about(self.raw_query, **self.kwargs)
def __repr__(self):
if self.field:
return _("Can {type} {model}.{field} in {query}").format(type=self.type, model=self.model, field=self.field, query=self.query)
else:
return _("Can {type} {model} in {query}").format(type=self.type, model=self.model, query=self.query)
def __str__(self):
return self.__repr__()
class PermissionMask(models.Model):
"""
Permissions that are hidden behind a mask
"""
rank = models.PositiveSmallIntegerField(
unique=True,
verbose_name=_('rank'),
)
description = models.CharField(
max_length=255,
unique=True,
verbose_name=_('description'),
)
def __str__(self):
return self.description
class Permission(models.Model):
PERMISSION_TYPES = [
('add', 'add'),
('view', 'view'),
('change', 'change'),
('delete', 'delete')
]
model = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name='+')
# A json encoded Q object with the following grammar
# query -> [] | {} (the empty query representing all objects)
# query -> ["AND", query, …] AND multiple queries
# | ["OR", query, …] OR multiple queries
# | ["NOT", query] Opposite of query
# query -> {key: value, …} A list of fields and values of a Q object
# key -> string A field name
# value -> int | string | bool | null Literal values
# | [parameter, …] A parameter. See compute_param for more details.
# | {"F": oper} An F object
# oper -> [string, …] A parameter. See compute_param for more details.
# | ["ADD", oper, …] Sum multiple F objects or literal
# | ["SUB", oper, oper] Substract two F objects or literal
# | ["MUL", oper, …] Multiply F objects or literals
# | int | string | bool | null Literal values
# | ["F", string] A field
#
# Examples:
# Q(is_superuser=True) := {"is_superuser": true}
# ~Q(is_superuser=True) := ["NOT", {"is_superuser": true}]
query = models.TextField()
type = models.CharField(max_length=15, choices=PERMISSION_TYPES)
mask = models.ForeignKey(
PermissionMask,
on_delete=models.PROTECT,
)
field = models.CharField(max_length=255, blank=True)
description = models.CharField(max_length=255, blank=True)
class Meta:
unique_together = ('model', 'query', 'type', 'field')
def clean(self):
self.query = json.dumps(json.loads(self.query))
if self.field and self.type not in {'view', 'change'}:
raise ValidationError(_("Specifying field applies only to view and change permission types."))
def save(self, **kwargs):
self.full_clean()
super().save()
@staticmethod
def compute_f(oper, **kwargs):
if isinstance(oper, list):
if oper[0] == 'ADD':
return functools.reduce(operator.add, [Permission.compute_f(oper, **kwargs) for oper in oper[1:]])
elif oper[0] == 'SUB':
return Permission.compute_f(oper[1], **kwargs) - Permission.compute_f(oper[2], **kwargs)
elif oper[0] == 'MUL':
return functools.reduce(operator.mul, [Permission.compute_f(oper, **kwargs) for oper in oper[1:]])
elif oper[0] == 'F':
return F(oper[1])
else:
field = kwargs[oper[0]]
for i in range(1, len(oper)):
field = getattr(field, oper[i])
return field
else:
return oper
@staticmethod
def compute_param(value, **kwargs):
"""
A parameter is given by a list. The first argument is the name of the parameter.
The parameters are the user, the club, and some classes (Note, ...)
If there are more arguments in the list, then attributes are queried.
For example, ["user", "note", "balance"] will return the balance of the note of the user.
If an argument is a list, then this is interpreted with a function call:
First argument is the name of the function, next arguments are parameters, and if there is a dict,
then the dict is given as kwargs.
For example: NoteUser.objects.filter(user__memberships__club__name="Kfet").all() is translated by:
["NoteUser", "objects", ["filter", {"user__memberships__club__name": "Kfet"}], ["all"]]
"""
if not isinstance(value, list):
return value
field = kwargs[value[0]]
for i in range(1, len(value)):
if isinstance(value[i], list):
if value[i][0] in kwargs:
field = Permission.compute_param(value[i], **kwargs)
continue
field = getattr(field, value[i][0])
params = []
call_kwargs = {}
for j in range(1, len(value[i])):
param = Permission.compute_param(value[i][j], **kwargs)
if isinstance(param, dict):
for key in param:
val = Permission.compute_param(param[key], **kwargs)
call_kwargs[key] = val
else:
params.append(param)
field = field(*params, **call_kwargs)
else:
field = getattr(field, value[i])
return field
@staticmethod
def _about(query, **kwargs):
"""
Translate JSON query into a Q query.
:param query: The JSON query
:param kwargs: Additional params
:return: A Q object
"""
if len(query) == 0:
# The query is either [] or {} and
# applies to all objects of the model
# to represent this we return a trivial request
return Q(pk=F("pk"))
if isinstance(query, list):
if query[0] == 'AND':
return functools.reduce(operator.and_, [Permission._about(query, **kwargs) for query in query[1:]])
elif query[0] == 'OR':
return functools.reduce(operator.or_, [Permission._about(query, **kwargs) for query in query[1:]])
elif query[0] == 'NOT':
return ~Permission._about(query[1], **kwargs)
else:
return Q(pk=F("pk"))
elif isinstance(query, dict):
q_kwargs = {}
for key in query:
value = query[key]
if isinstance(value, list):
# It is a parameter we query its return value
q_kwargs[key] = Permission.compute_param(value, **kwargs)
elif isinstance(value, dict):
# It is an F object
q_kwargs[key] = Permission.compute_f(value['F'], **kwargs)
else:
q_kwargs[key] = value
return Q(**q_kwargs)
else:
# TODO: find a better way to crash here
raise Exception("query {} is wrong".format(query))
def about(self, **kwargs):
"""
Return an InstancedPermission with the parameters
replaced by their values and the query interpreted
"""
query = json.loads(self.query)
# query = self._about(query, **kwargs)
return InstancedPermission(self.model, query, self.type, self.field, self.mask, **kwargs)
def __str__(self):
if self.field:
return _("Can {type} {model}.{field} in {query}").format(type=self.type, model=self.model, field=self.field, query=self.query)
else:
return _("Can {type} {model} in {query}").format(type=self.type, model=self.model, query=self.query)
class RolePermissions(models.Model):
"""
Permissions associated with a Role
"""
role = models.ForeignKey(
Role,
on_delete=models.PROTECT,
related_name='+',
verbose_name=_('role'),
)
permissions = models.ManyToManyField(
Permission,
)
def __str__(self):
return str(self.role)

View File

@ -0,0 +1,63 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from rest_framework.permissions import DjangoObjectPermissions
SAFE_METHODS = ('HEAD', 'OPTIONS', )
class StrongDjangoObjectPermissions(DjangoObjectPermissions):
"""
Default DjangoObjectPermissions grant view permission to all.
This is a simple patch of this class that controls view access.
"""
perms_map = {
'GET': ['%(app_label)s.view_%(model_name)s'],
'OPTIONS': [],
'HEAD': [],
'POST': ['%(app_label)s.add_%(model_name)s'],
'PUT': ['%(app_label)s.change_%(model_name)s'],
'PATCH': ['%(app_label)s.change_%(model_name)s'],
'DELETE': ['%(app_label)s.delete_%(model_name)s'],
}
def get_required_object_permissions(self, method, model_cls):
kwargs = {
'app_label': model_cls._meta.app_label,
'model_name': model_cls._meta.model_name
}
if method not in self.perms_map:
from rest_framework import exceptions
raise exceptions.MethodNotAllowed(method)
return [perm % kwargs for perm in self.perms_map[method]]
def has_object_permission(self, request, view, obj):
# authentication checks have already executed via has_permission
queryset = self._queryset(view)
model_cls = queryset.model
user = request.user
perms = self.get_required_object_permissions(request.method, model_cls)
if not user.has_perms(perms, obj):
# If the user does not have permissions we need to determine if
# they have read permissions to see 403, or not, and simply see
# a 404 response.
from django.http import Http404
if request.method in SAFE_METHODS:
# Read permissions already checked and failed, no need
# to make another lookup.
raise Http404
read_perms = self.get_required_object_permissions('GET', model_cls)
if not user.has_perms(read_perms, obj):
raise Http404
# Has read permissions.
return False
return True

106
apps/permission/signals.py Normal file
View File

@ -0,0 +1,106 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.core.exceptions import PermissionDenied
from django.db.models.signals import pre_save, pre_delete, post_save, post_delete
from logs import signals as logs_signals
from permission.backends import PermissionBackend
from note_kfet.middlewares import get_current_authenticated_user
EXCLUDED = [
'cas_server.proxygrantingticket',
'cas_server.proxyticket',
'cas_server.serviceticket',
'cas_server.user',
'cas_server.userattributes',
'contenttypes.contenttype',
'logs.changelog',
'migrations.migration',
'sessions.session',
]
def pre_save_object(sender, instance, **kwargs):
"""
Before a model get saved, we check the permissions
"""
# noinspection PyProtectedMember
if instance._meta.label_lower in EXCLUDED:
return
user = get_current_authenticated_user()
if user is None:
# Action performed on shell is always granted
return
qs = sender.objects.filter(pk=instance.pk).all()
model_name_full = instance._meta.label_lower.split(".")
app_label = model_name_full[0]
model_name = model_name_full[1]
if qs.exists():
# We check if the user can change the model
# If the user has all right on a model, then OK
if PermissionBackend().has_perm(user, app_label + ".change_" + model_name, instance):
return
# In the other case, we check if he/she has the right to change one field
previous = qs.get()
for field in instance._meta.fields:
field_name = field.name
old_value = getattr(previous, field.name)
new_value = getattr(instance, field.name)
# If the field wasn't modified, no need to check the permissions
if old_value == new_value:
continue
if not PermissionBackend().has_perm(user, app_label + ".change_" + model_name + "_" + field_name, instance):
raise PermissionDenied
else:
# We check if the user can add the model
# While checking permissions, the object will be inserted in the DB, then removed.
# We disable temporary the connectors
pre_save.disconnect(pre_save_object)
pre_delete.disconnect(pre_delete_object)
# We disable also logs connectors
pre_save.disconnect(logs_signals.pre_save_object)
post_save.disconnect(logs_signals.save_object)
post_delete.disconnect(logs_signals.delete_object)
# We check if the user has right to add the object
has_perm = PermissionBackend().has_perm(user, app_label + ".add_" + model_name, instance)
# Then we reconnect all
pre_save.connect(pre_save_object)
pre_delete.connect(pre_delete_object)
pre_save.connect(logs_signals.pre_save_object)
post_save.connect(logs_signals.save_object)
post_delete.connect(logs_signals.delete_object)
if not has_perm:
raise PermissionDenied
def pre_delete_object(sender, instance, **kwargs):
"""
Before a model get deleted, we check the permissions
"""
# noinspection PyProtectedMember
if instance._meta.label_lower in EXCLUDED:
return
user = get_current_authenticated_user()
if user is None:
# Action performed on shell is always granted
return
model_name_full = instance._meta.label_lower.split(".")
app_label = model_name_full[0]
model_name = model_name_full[1]
# We check if the user has rights to delete the object
if not PermissionBackend().has_perm(user, app_label + ".delete_" + model_name, instance):
raise PermissionDenied

View File

View File

@ -0,0 +1,55 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.contenttypes.models import ContentType
from django.template.defaultfilters import stringfilter
from note_kfet.middlewares import get_current_authenticated_user, get_current_session
from django import template
from permission.backends import PermissionBackend
@stringfilter
def not_empty_model_list(model_name):
"""
Return True if and only if the current user has right to see any object of the given model.
"""
user = get_current_authenticated_user()
session = get_current_session()
if user is None:
return False
elif user.is_superuser and session.get("permission_mask", 0) >= 42:
return True
if session.get("not_empty_model_list_" + model_name, None):
return session.get("not_empty_model_list_" + model_name, None) == 1
spl = model_name.split(".")
ct = ContentType.objects.get(app_label=spl[0], model=spl[1])
qs = ct.model_class().objects.filter(PermissionBackend.filter_queryset(user, ct, "view")).all()
session["not_empty_model_list_" + model_name] = 1 if qs.exists() else 2
return session.get("not_empty_model_list_" + model_name) == 1
@stringfilter
def not_empty_model_change_list(model_name):
"""
Return True if and only if the current user has right to change any object of the given model.
"""
user = get_current_authenticated_user()
session = get_current_session()
if user is None:
return False
elif user.is_superuser and session.get("permission_mask", 0) >= 42:
return True
if session.get("not_empty_model_change_list_" + model_name, None):
return session.get("not_empty_model_change_list_" + model_name, None) == 1
spl = model_name.split(".")
ct = ContentType.objects.get(app_label=spl[0], model=spl[1])
qs = ct.model_class().objects.filter(PermissionBackend.filter_queryset(user, ct, "change"))
session["not_empty_model_change_list_" + model_name] = 1 if qs.exists() else 2
return session.get("not_empty_model_change_list_" + model_name) == 1
register = template.Library()
register.filter('not_empty_model_list', not_empty_model_list)
register.filter('not_empty_model_change_list', not_empty_model_change_list)

View File

@ -7,7 +7,7 @@ if [ -z ${NOTE_URL+x} ]; then
else
sed -i -e "s/example.com/$DOMAIN/g" /code/apps/member/fixtures/initial.json
sed -i -e "s/localhost/$NOTE_URL/g" /code/note_kfet/fixtures/initial.json
sed -i -e "s/\.\*/https?:\/\/$NOTE_URL\/.*/g" /code/note_kfet/fixtures/cas.json
sed -i -e "s/\"\.\*\"/\"https?:\/\/$NOTE_URL\/.*\"/g" /code/note_kfet/fixtures/cas.json
sed -i -e "s/REPLACEME/La Note Kfet \\\\ud83c\\\\udf7b/g" /code/note_kfet/fixtures/cas.json
fi

View File

@ -1,6 +1,66 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.contrib.auth.models import AnonymousUser, User
from threading import local
from django.contrib.sessions.backends.db import SessionStore
USER_ATTR_NAME = getattr(settings, 'LOCAL_USER_ATTR_NAME', '_current_user')
SESSION_ATTR_NAME = getattr(settings, 'LOCAL_SESSION_ATTR_NAME', '_current_session')
IP_ATTR_NAME = getattr(settings, 'LOCAL_IP_ATTR_NAME', '_current_ip')
_thread_locals = local()
def _set_current_user_and_ip(user=None, session=None, ip=None):
setattr(_thread_locals, USER_ATTR_NAME, user)
setattr(_thread_locals, SESSION_ATTR_NAME, session)
setattr(_thread_locals, IP_ATTR_NAME, ip)
def get_current_user() -> User:
return getattr(_thread_locals, USER_ATTR_NAME, None)
def get_current_session() -> SessionStore:
return getattr(_thread_locals, SESSION_ATTR_NAME, None)
def get_current_ip() -> str:
return getattr(_thread_locals, IP_ATTR_NAME, None)
def get_current_authenticated_user():
current_user = get_current_user()
if isinstance(current_user, AnonymousUser):
return None
return current_user
class SessionMiddleware(object):
"""
This middleware get the current user with his or her IP address on each request.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
user = request.user
if 'HTTP_X_FORWARDED_FOR' in request.META:
ip = request.META.get('HTTP_X_FORWARDED_FOR')
else:
ip = request.META.get('REMOTE_ADDR')
_set_current_user_and_ip(user, request.session, ip)
response = self.get_response(request)
_set_current_user_and_ip(None, None, None)
return response
class TurbolinksMiddleware(object):
"""

View File

@ -76,7 +76,7 @@ if "cas" in INSTALLED_APPS:
if "logs" in INSTALLED_APPS:
MIDDLEWARE += ('logs.middlewares.LogsMiddleware',)
MIDDLEWARE += ('note_kfet.middlewares.SessionMiddleware',)
if "debug_toolbar" in INSTALLED_APPS:
MIDDLEWARE.insert(1, "debug_toolbar.middleware.DebugToolbarMiddleware")

View File

@ -59,6 +59,7 @@ INSTALLED_APPS = [
'activity',
'member',
'note',
'permission',
'api',
'logs',
]
@ -124,18 +125,15 @@ PASSWORD_HASHERS = [
'member.hashers.CustomNK15Hasher',
]
# Django Guardian object permissions
AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend', # this is default
'permission.backends.PermissionBackend', # Custom role-based permission system
'cas.backends.CASBackend', # For CAS connections
)
REST_FRAMEWORK = {
# Use Django's standard `django.contrib.auth` permissions,
# or allow read-only access for unauthenticated users.
'DEFAULT_PERMISSION_CLASSES': [
# TODO Maybe replace it with our custom permissions system
'rest_framework.permissions.DjangoModelPermissions',
# Control API access with our role-based permission system
'permission.permissions.StrongDjangoObjectPermissions',
],
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.SessionAuthentication',

View File

@ -7,6 +7,8 @@ from django.contrib import admin
from django.urls import path, include
from django.views.generic import RedirectView
from member.views import CustomLoginView
urlpatterns = [
# Dev so redirect to something random
path('', RedirectView.as_view(pattern_name='note:transfer'), name='index'),
@ -16,10 +18,11 @@ urlpatterns = [
# Include Django Contrib and Core routers
path('i18n/', include('django.conf.urls.i18n')),
path('accounts/', include('member.urls')),
path('accounts/', include('django.contrib.auth.urls')),
path('admin/doc/', include('django.contrib.admindocs.urls')),
path('admin/', admin.site.urls),
path('accounts/', include('member.urls')),
path('accounts/login/', CustomLoginView.as_view()),
path('accounts/', include('django.contrib.auth.urls')),
path('api/', include('api.urls')),
]

View File

@ -61,13 +61,23 @@ function li(id, text) {
* @param profile_pic_field
*/
function displayNote(note, alias, user_note_field=null, profile_pic_field=null) {
let img = note == null ? null : note.display_image;
if (img == null)
img = '/media/pic/default.png';
if (note !== null && alias !== note.name)
if (!note.display_image) {
note.display_image = 'https://nk20.ynerant.fr/media/pic/default.png';
$.getJSON("/api/note/note/" + note.id + "/?format=json", function(new_note) {
note.display_image = new_note.display_image.replace("http:", "https:");
note.name = new_note.name;
note.balance = new_note.balance;
displayNote(note, alias, user_note_field, profile_pic_field);
});
return;
}
let img = note.display_image;
if (alias !== note.name)
alias += " (aka. " + note.name + ")";
if (note !== null && user_note_field !== null)
$("#" + user_note_field).text(alias + " : " + pretty_money(note.balance));
if (user_note_field !== null)
$("#" + user_note_field).text(alias + (note.balance == null ? "" : (" : " + pretty_money(note.balance))));
if (profile_pic_field != null)
$("#" + profile_pic_field).attr('src', img);
}
@ -173,8 +183,13 @@ function autoCompleteNote(field_id, alias_matched_id, note_list_id, notes, notes
aliases.results.forEach(function (alias) {
let note = alias.note;
note = {
id: note,
name: alias.name,
alias: alias,
balance: null
};
aliases_matched_html += li(alias_prefix + "_" + alias.id, alias.name);
note.alias = alias;
notes.push(note);
});
@ -192,6 +207,7 @@ function autoCompleteNote(field_id, alias_matched_id, note_list_id, notes, notes
// When the user click on an alias, the associated note is added to the emitters
alias_obj.click(function () {
field.val("");
old_pattern = "";
// If the note is already an emitter, we increase the quantity
var disp = null;
notes_display.forEach(function (d) {
@ -258,7 +274,7 @@ function de_validate(id, validated) {
"X-CSRFTOKEN": CSRF_TOKEN
},
data: {
"resourcetype": "TemplateTransaction",
"resourcetype": "RecurrentTransaction",
valid: !validated
},
success: function () {

View File

@ -97,7 +97,7 @@ autoCompleteNote("note", "alias_matched", "note_list", notes, notes_display,
* Add a transaction from a button.
* @param dest Where the money goes
* @param amount The price of the item
* @param type The type of the transaction (content type id for TemplateTransaction)
* @param type The type of the transaction (content type id for RecurrentTransaction)
* @param category_id The category identifier
* @param category_name The category name
* @param template_id The identifier of the button
@ -154,7 +154,8 @@ function reset() {
$("#note_list").html("");
$("#alias_matched").html("");
$("#consos_list").html("");
displayNote(null, "");
$("#user_note").text("");
$("#profile_pic").attr("src", "/media/pic/default.png");
refreshHistory();
refreshBalance();
}
@ -179,7 +180,7 @@ function consumeAll() {
* @param quantity The quantity sold (type: int)
* @param amount The price of one item, in cents (type: int)
* @param reason The transaction details (type: str)
* @param type The type of the transaction (content type id for TemplateTransaction)
* @param type The type of the transaction (content type id for RecurrentTransaction)
* @param category The category id of the button (type: int)
* @param template The button id (type: int)
*/
@ -192,7 +193,7 @@ function consume(source, dest, quantity, amount, reason, type, category, templat
"reason": reason,
"valid": true,
"polymorphic_ctype": type,
"resourcetype": "TemplateTransaction",
"resourcetype": "RecurrentTransaction",
"source": source,
"destination": dest,
"category": category,

View File

@ -21,6 +21,8 @@ function reset() {
$("#last_name").val("");
$("#first_name").val("");
$("#bank").val("");
$("#user_note").val("");
$("#profile_pic").attr("src", "/media/pic/default.png");
refreshBalance();
refreshHistory();
}
@ -30,16 +32,18 @@ $(document).ready(function() {
"source_alias", "source_note", "user_note", "profile_pic");
autoCompleteNote("dest_note", "dest_alias_matched", "dest_note_list", dests, dests_notes_display,
"dest_alias", "dest_note", "user_note", "profile_pic", function() {
let last = dests_notes_display[dests_notes_display.length - 1];
dests_notes_display.length = 0;
dests_notes_display.push(last);
if ($("#type_credit").is(":checked") || $("#type_debit").is(":checked")) {
let last = dests_notes_display[dests_notes_display.length - 1];
dests_notes_display.length = 0;
dests_notes_display.push(last);
last.quantity = 1;
last.quantity = 1;
$.getJSON("/api/user/" + last.note.user + "/", function(user) {
$("#last_name").val(user.last_name);
$("#first_name").val(user.first_name);
});
$.getJSON("/api/user/" + last.note.user + "/", function(user) {
$("#last_name").val(user.last_name);
$("#first_name").val(user.first_name);
});
}
return true;
});

View File

@ -1,4 +1,4 @@
{% load static i18n pretty_money static getenv %}
{% load static i18n pretty_money static getenv perms %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
@ -74,21 +74,29 @@ SPDX-License-Identifier: GPL-3.0-or-later
</button>
<div class="collapse navbar-collapse" id="navbarNavDropdown">
<ul class="navbar-nav">
<li class="nav-item active">
<a class="nav-link" href="{% url 'note:consos' %}"><i class="fa fa-coffee"></i> {% trans 'Consumptions' %}</a>
</li>
<li class="nav-item active">
<a class="nav-link" href="{% url 'member:club_list' %}"><i class="fa fa-users"></i> {% trans 'Clubs' %}</a>
</li>
<li class="nav-item active">
<a class="nav-link" href="#"><i class="fa fa-calendar"></i> {% trans 'Activities' %}</a>
</li>
<li class="nav-item active">
<a class="nav-link" href="{% url 'note:template_list' %}"><i class="fa fa-coffee"></i> {% trans 'Buttons' %}</a>
</li>
<li class="nav-item active">
<a class="nav-link" href="{% url 'note:transfer' %}"><i class="fa fa-exchange"></i>{% trans 'Transfer' %} </a>
</li>
{% if "note.transactiontemplate"|not_empty_model_list %}
<li class="nav-item active">
<a class="nav-link" href="{% url 'note:consos' %}"><i class="fa fa-coffee"></i> {% trans 'Consumptions' %}</a>
</li>
{% endif %}
{% if "member.club"|not_empty_model_list %}
<li class="nav-item active">
<a class="nav-link" href="{% url 'member:club_list' %}"><i class="fa fa-users"></i> {% trans 'Clubs' %}</a>
</li>
{% endif %}
{% if "activity.activity"|not_empty_model_list %}
<li class="nav-item active">
<a class="nav-link" href="#"><i class="fa fa-calendar"></i> {% trans 'Activities' %}</a>
</li>
{% endif %}
{% if "note.transactiontemplate"|not_empty_model_change_list %}
<li class="nav-item active">
<a class="nav-link" href="{% url 'note:template_list' %}"><i class="fa fa-coffee"></i> {% trans 'Buttons' %}</a>
</li>
{% endif %}
<li class="nav-item active">
<a class="nav-link" href="{% url 'note:transfer' %}"><i class="fa fa-exchange"></i>{% trans 'Transfer' %} </a>
</li>
</ul>
<ul class="navbar-nav ml-auto">
{% if user.is_authenticated %}

View File

@ -3,7 +3,7 @@
SPDX-License-Identifier: GPL-2.0-or-later
{% endcomment %}
{% load i18n static django_tables2 %}
{% load i18n static django_tables2 perms %}
{% block content %}
@ -18,14 +18,16 @@ SPDX-License-Identifier: GPL-2.0-or-later
<input type="radio" name="transaction_type" id="type_transfer">
{% trans "Transfer" %}
</label>
<label for="type_credit" class="btn btn-sm btn-outline-primary">
<input type="radio" name="transaction_type" id="type_credit">
{% trans "Credit" %}
</label>
<label type="type_debit" class="btn btn-sm btn-outline-primary">
<input type="radio" name="transaction_type" id="type_debit">
{% trans "Debit" %}
</label>
{% if "note.notespecial"|not_empty_model_list %}
<label for="type_credit" class="btn btn-sm btn-outline-primary">
<input type="radio" name="transaction_type" id="type_credit">
{% trans "Credit" %}
</label>
<label type="type_debit" class="btn btn-sm btn-outline-primary">
<input type="radio" name="transaction_type" id="type_debit">
{% trans "Debit" %}
</label>
{% endif %}
</div>
</div>
</div>
@ -58,47 +60,49 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</div>
<div class="col-md-4" id="external_div" style="display: none;">
<div class="card border-success shadow mb-4">
<div class="card-header">
<p class="card-text font-weight-bold">
{% trans "External payment" %}
</p>
</div>
<ul class="list-group list-group-flush" id="source_note_list">
</ul>
<div class="card-body">
<div class="form-row">
<div class="col-md-12">
<label for="credit_type">{% trans "Transfer type" %} :</label>
<select id="credit_type" class="custom-select">
{% for special_type in special_types %}
<option value="{{ special_type.id }}">{{ special_type.special_type }}</option>
{% endfor %}
</select>
</div>
{% if "note.notespecial"|not_empty_model_list %}
<div class="col-md-4" id="external_div" style="display: none;">
<div class="card border-success shadow mb-4">
<div class="card-header">
<p class="card-text font-weight-bold">
{% trans "External payment" %}
</p>
</div>
<div class="form-row">
<div class="col-md-12">
<label for="last_name">{% trans "Name" %} :</label>
<input type="text" id="last_name" class="form-control" />
<ul class="list-group list-group-flush" id="source_note_list">
</ul>
<div class="card-body">
<div class="form-row">
<div class="col-md-12">
<label for="credit_type">{% trans "Transfer type" %} :</label>
<select id="credit_type" class="custom-select">
{% for special_type in special_types %}
<option value="{{ special_type.id }}">{{ special_type.special_type }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="form-row">
<div class="col-md-12">
<label for="first_name">{% trans "First name" %} :</label>
<input type="text" id="first_name" class="form-control" />
<div class="form-row">
<div class="col-md-12">
<label for="last_name">{% trans "Name" %} :</label>
<input type="text" id="last_name" class="form-control" />
</div>
</div>
</div>
<div class="form-row">
<div class="col-md-12">
<label for="bank">{% trans "Bank" %} :</label>
<input type="text" id="bank" class="form-control" />
<div class="form-row">
<div class="col-md-12">
<label for="first_name">{% trans "First name" %} :</label>
<input type="text" id="first_name" class="form-control" />
</div>
</div>
<div class="form-row">
<div class="col-md-12">
<label for="bank">{% trans "Bank" %} :</label>
<input type="text" id="bank" class="form-control" />
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<div class="col-md-8" id="dests_div">
<div class="card border-info shadow mb-4">