mirror of
https://gitlab.crans.org/bde/nk20
synced 2024-11-26 18:37:12 +00:00
Merge remote-tracking branch 'origin/master' into tresorerie
# Conflicts: # locale/de/LC_MESSAGES/django.po # locale/fr/LC_MESSAGES/django.po # note_kfet/settings/base.py # templates/base.html
This commit is contained in:
commit
18f6daf2ac
13
README.md
13
README.md
@ -36,6 +36,7 @@ On supposera pour la suite que vous utilisez Debian/Ubuntu sur un serveur tout n
|
|||||||
$ python3 -m venv env
|
$ python3 -m venv env
|
||||||
$ source env/bin/activate
|
$ source env/bin/activate
|
||||||
(env)$ pip3 install -r requirements/base.txt
|
(env)$ pip3 install -r requirements/base.txt
|
||||||
|
(env)$ pip3 install -r requirements/prod.txt # uniquement en prod, nécessite un base postgres
|
||||||
(env)$ deactivate
|
(env)$ deactivate
|
||||||
|
|
||||||
4. uwsgi et Nginx
|
4. uwsgi et Nginx
|
||||||
@ -105,18 +106,18 @@ On supposera pour la suite que vous utilisez Debian/Ubuntu sur un serveur tout n
|
|||||||
On copie le fichier `.env_example` vers le fichier `.env` à la racine du projet
|
On copie le fichier `.env_example` vers le fichier `.env` à la racine du projet
|
||||||
et on renseigne des secrets et des paramètres :
|
et on renseigne des secrets et des paramètres :
|
||||||
|
|
||||||
DJANGO_APP_STAGE="dev"
|
DJANGO_APP_STAGE="dev" # ou "prod"
|
||||||
DJANGO_DEV_STORE_METHOD="sqllite"
|
DJANGO_DEV_STORE_METHOD="sqllite" # ou "postgres"
|
||||||
DJANGO_DB_HOST="localhost"
|
DJANGO_DB_HOST="localhost"
|
||||||
DJANGO_DB_NAME="note_db"
|
DJANGO_DB_NAME="note_db"
|
||||||
DJANGO_DB_USER="note"
|
DJANGO_DB_USER="note"
|
||||||
DJANGO_DB_PASSWORD="CHANGE_ME"
|
DJANGO_DB_PASSWORD="CHANGE_ME"
|
||||||
DJANGO_DB_PORT=""
|
DJANGO_DB_PORT=""
|
||||||
DJANGO_SECRET_KEY="CHANGE_ME"
|
DJANGO_SECRET_KEY="CHANGE_ME"
|
||||||
DJANGO_SETTINGS_MODULE="note_kfet.settings"
|
DJANGO_SETTINGS_MODULE="note_kfet.settings"
|
||||||
DOMAIN="localhost"
|
DOMAIN="localhost" # note.example.com
|
||||||
CONTACT_EMAIL="tresorerie.bde@localhost"
|
CONTACT_EMAIL="tresorerie.bde@localhost"
|
||||||
NOTE_URL="localhost"
|
NOTE_URL="localhost" # serveur cas note.example.com si auto-hébergé.
|
||||||
|
|
||||||
Ensuite on (re)bascule dans l'environement virtuel et on lance les migrations
|
Ensuite on (re)bascule dans l'environement virtuel et on lance les migrations
|
||||||
|
|
||||||
@ -171,7 +172,7 @@ un serveur de développement par exemple sur son ordinateur.
|
|||||||
|
|
||||||
$ python3 -m venv venv
|
$ python3 -m venv venv
|
||||||
$ source venv/bin/activate
|
$ source venv/bin/activate
|
||||||
(env)$ pip install -r requirements.txt
|
(env)$ pip install -r requirements/base.txt
|
||||||
|
|
||||||
3. Copier le fichier `.env_example` vers `.env` à la racine du projet et mettre à jour
|
3. Copier le fichier `.env_example` vers `.env` à la racine du projet et mettre à jour
|
||||||
ce qu'il faut
|
ce qu'il faut
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from rest_framework import viewsets
|
|
||||||
from rest_framework.filters import SearchFilter
|
from rest_framework.filters import SearchFilter
|
||||||
|
from api.viewsets import ReadProtectedModelViewSet
|
||||||
|
|
||||||
from .serializers import ActivityTypeSerializer, ActivitySerializer, GuestSerializer
|
from .serializers import ActivityTypeSerializer, ActivitySerializer, GuestSerializer
|
||||||
from ..models import ActivityType, Activity, Guest
|
from ..models import ActivityType, Activity, Guest
|
||||||
|
|
||||||
|
|
||||||
class ActivityTypeViewSet(viewsets.ModelViewSet):
|
class ActivityTypeViewSet(ReadProtectedModelViewSet):
|
||||||
"""
|
"""
|
||||||
REST API View set.
|
REST API View set.
|
||||||
The djangorestframework plugin will get all `ActivityType` objects, serialize it to JSON with the given serializer,
|
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', ]
|
filterset_fields = ['name', 'can_invite', ]
|
||||||
|
|
||||||
|
|
||||||
class ActivityViewSet(viewsets.ModelViewSet):
|
class ActivityViewSet(ReadProtectedModelViewSet):
|
||||||
"""
|
"""
|
||||||
REST API View set.
|
REST API View set.
|
||||||
The djangorestframework plugin will get all `Activity` objects, serialize it to JSON with the given serializer,
|
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', ]
|
filterset_fields = ['name', 'description', 'activity_type', ]
|
||||||
|
|
||||||
|
|
||||||
class GuestViewSet(viewsets.ModelViewSet):
|
class GuestViewSet(ReadProtectedModelViewSet):
|
||||||
"""
|
"""
|
||||||
REST API View set.
|
REST API View set.
|
||||||
The djangorestframework plugin will get all `Guest` objects, serialize it to JSON with the given serializer,
|
The djangorestframework plugin will get all `Guest` objects, serialize it to JSON with the given serializer,
|
||||||
|
@ -5,12 +5,15 @@ from django.conf.urls import url, include
|
|||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
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.filters import SearchFilter
|
||||||
|
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||||
from activity.api.urls import register_activity_urls
|
from activity.api.urls import register_activity_urls
|
||||||
|
from api.viewsets import ReadProtectedModelViewSet
|
||||||
from member.api.urls import register_members_urls
|
from member.api.urls import register_members_urls
|
||||||
from note.api.urls import register_note_urls
|
from note.api.urls import register_note_urls
|
||||||
from logs.api.urls import register_logs_urls
|
from logs.api.urls import register_logs_urls
|
||||||
|
from permission.api.urls import register_permission_urls
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
@ -39,7 +42,7 @@ class ContentTypeSerializer(serializers.ModelSerializer):
|
|||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
class UserViewSet(viewsets.ModelViewSet):
|
class UserViewSet(ReadProtectedModelViewSet):
|
||||||
"""
|
"""
|
||||||
REST API View set.
|
REST API View set.
|
||||||
The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
|
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', ]
|
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.
|
REST API View set.
|
||||||
The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
|
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_members_urls(router, 'members')
|
||||||
register_activity_urls(router, 'activity')
|
register_activity_urls(router, 'activity')
|
||||||
register_note_urls(router, 'note')
|
register_note_urls(router, 'note')
|
||||||
|
register_permission_urls(router, 'permission')
|
||||||
register_logs_urls(router, 'logs')
|
register_logs_urls(router, 'logs')
|
||||||
|
|
||||||
app_name = 'api'
|
app_name = 'api'
|
||||||
|
31
apps/api/viewsets.py
Normal file
31
apps/api/viewsets.py
Normal 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"))
|
@ -2,14 +2,14 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from rest_framework import viewsets
|
|
||||||
from rest_framework.filters import OrderingFilter
|
from rest_framework.filters import OrderingFilter
|
||||||
|
from api.viewsets import ReadOnlyProtectedModelViewSet
|
||||||
|
|
||||||
from .serializers import ChangelogSerializer
|
from .serializers import ChangelogSerializer
|
||||||
from ..models import Changelog
|
from ..models import Changelog
|
||||||
|
|
||||||
|
|
||||||
class ChangelogViewSet(viewsets.ReadOnlyModelViewSet):
|
class ChangelogViewSet(ReadOnlyProtectedModelViewSet):
|
||||||
"""
|
"""
|
||||||
REST API View set.
|
REST API View set.
|
||||||
The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer,
|
The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer,
|
||||||
|
@ -1,55 +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):
|
|
||||||
setattr(_thread_locals, USER_ATTR_NAME, user)
|
|
||||||
setattr(_thread_locals, IP_ATTR_NAME, ip)
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_user():
|
|
||||||
return getattr(_thread_locals, USER_ATTR_NAME, None)
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_ip():
|
|
||||||
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 LogsMiddleware(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, ip)
|
|
||||||
response = self.get_response(request)
|
|
||||||
_set_current_user_and_ip(None, None)
|
|
||||||
|
|
||||||
return response
|
|
@ -4,14 +4,13 @@
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from rest_framework.renderers import JSONRenderer
|
from rest_framework.renderers import JSONRenderer
|
||||||
from rest_framework.serializers import ModelSerializer
|
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
|
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
|
# Ces modèles ne nécessitent pas de logs
|
||||||
EXCLUDED = [
|
EXCLUDED = [
|
||||||
|
@ -15,6 +15,7 @@ class ProfileSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Profile
|
model = Profile
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
read_only_fields = ('user', )
|
||||||
|
|
||||||
|
|
||||||
class ClubSerializer(serializers.ModelSerializer):
|
class ClubSerializer(serializers.ModelSerializer):
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from rest_framework import viewsets
|
|
||||||
from rest_framework.filters import SearchFilter
|
from rest_framework.filters import SearchFilter
|
||||||
|
from api.viewsets import ReadProtectedModelViewSet
|
||||||
|
|
||||||
from .serializers import ProfileSerializer, ClubSerializer, RoleSerializer, MembershipSerializer
|
from .serializers import ProfileSerializer, ClubSerializer, RoleSerializer, MembershipSerializer
|
||||||
from ..models import Profile, Club, Role, Membership
|
from ..models import Profile, Club, Role, Membership
|
||||||
|
|
||||||
|
|
||||||
class ProfileViewSet(viewsets.ModelViewSet):
|
class ProfileViewSet(ReadProtectedModelViewSet):
|
||||||
"""
|
"""
|
||||||
REST API View set.
|
REST API View set.
|
||||||
The djangorestframework plugin will get all `Profile` objects, serialize it to JSON with the given serializer,
|
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
|
serializer_class = ProfileSerializer
|
||||||
|
|
||||||
|
|
||||||
class ClubViewSet(viewsets.ModelViewSet):
|
class ClubViewSet(ReadProtectedModelViewSet):
|
||||||
"""
|
"""
|
||||||
REST API View set.
|
REST API View set.
|
||||||
The djangorestframework plugin will get all `Club` objects, serialize it to JSON with the given serializer,
|
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', ]
|
search_fields = ['$name', ]
|
||||||
|
|
||||||
|
|
||||||
class RoleViewSet(viewsets.ModelViewSet):
|
class RoleViewSet(ReadProtectedModelViewSet):
|
||||||
"""
|
"""
|
||||||
REST API View set.
|
REST API View set.
|
||||||
The djangorestframework plugin will get all `Role` objects, serialize it to JSON with the given serializer,
|
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', ]
|
search_fields = ['$name', ]
|
||||||
|
|
||||||
|
|
||||||
class MembershipViewSet(viewsets.ModelViewSet):
|
class MembershipViewSet(ReadProtectedModelViewSet):
|
||||||
"""
|
"""
|
||||||
REST API View set.
|
REST API View set.
|
||||||
The djangorestframework plugin will get all `Membership` objects, serialize it to JSON with the given serializer,
|
The djangorestframework plugin will get all `Membership` objects, serialize it to JSON with the given serializer,
|
||||||
|
@ -6,12 +6,21 @@ from crispy_forms.helper import FormHelper
|
|||||||
from crispy_forms.layout import Layout
|
from crispy_forms.layout import Layout
|
||||||
from dal import autocomplete
|
from dal import autocomplete
|
||||||
from django import forms
|
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 django.contrib.auth.models import User
|
||||||
|
from permission.models import PermissionMask
|
||||||
|
|
||||||
from .models import Profile, Club, Membership
|
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):
|
class SignUpForm(UserCreationForm):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse, reverse_lazy
|
from django.urls import reverse, reverse_lazy
|
||||||
@ -150,16 +152,13 @@ class Membership(models.Model):
|
|||||||
verbose_name=_('fee'),
|
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:
|
class Meta:
|
||||||
verbose_name = _('membership')
|
verbose_name = _('membership')
|
||||||
verbose_name_plural = _('memberships')
|
verbose_name_plural = _('memberships')
|
||||||
indexes = [models.Index(fields=['user'])]
|
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()
|
|
||||||
|
@ -9,6 +9,7 @@ from django.conf import settings
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.contrib.auth.views import LoginView
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
@ -23,13 +24,23 @@ from note.forms import AliasForm, ImageForm
|
|||||||
from note.models import Alias, NoteUser
|
from note.models import Alias, NoteUser
|
||||||
from note.models.transactions import Transaction
|
from note.models.transactions import Transaction
|
||||||
from note.tables import HistoryTable, AliasTable
|
from note.tables import HistoryTable, AliasTable
|
||||||
|
from permission.backends import PermissionBackend
|
||||||
|
|
||||||
from .filters import UserFilter, UserFilterFormHelper
|
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 .models import Club, Membership
|
||||||
from .tables import ClubTable, UserTable
|
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):
|
class UserCreateView(CreateView):
|
||||||
"""
|
"""
|
||||||
Une vue pour inscrire un utilisateur et lui créer un profile
|
Une vue pour inscrire un utilisateur et lui créer un profile
|
||||||
@ -120,11 +131,14 @@ class UserDetailView(LoginRequiredMixin, DetailView):
|
|||||||
context_object_name = "user_object"
|
context_object_name = "user_object"
|
||||||
template_name = "member/profile_detail.html"
|
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):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
user = context['user_object']
|
user = context['user_object']
|
||||||
history_list = \
|
history_list = \
|
||||||
Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note))
|
Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note)).order_by("-id")
|
||||||
context['history_list'] = HistoryTable(history_list)
|
context['history_list'] = HistoryTable(history_list)
|
||||||
club_list = \
|
club_list = \
|
||||||
Membership.objects.all().filter(user=user).only("club")
|
Membership.objects.all().filter(user=user).only("club")
|
||||||
@ -147,7 +161,7 @@ class UserListView(LoginRequiredMixin, SingleTableView):
|
|||||||
formhelper_class = UserFilterFormHelper
|
formhelper_class = UserFilterFormHelper
|
||||||
|
|
||||||
def get_queryset(self, **kwargs):
|
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 = self.filter_class(self.request.GET, queryset=qs)
|
||||||
self.filter.form.helper = self.formhelper_class()
|
self.filter.form.helper = self.formhelper_class()
|
||||||
return self.filter.qs
|
return self.filter.qs
|
||||||
@ -203,7 +217,6 @@ class DeleteAliasView(LoginRequiredMixin, DeleteView):
|
|||||||
return HttpResponseRedirect(self.get_success_url())
|
return HttpResponseRedirect(self.get_success_url())
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
print(self.request)
|
|
||||||
return reverse_lazy('member:user_alias', kwargs={'pk': self.object.note.user.pk})
|
return reverse_lazy('member:user_alias', kwargs={'pk': self.object.note.user.pk})
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
@ -297,7 +310,7 @@ class UserAutocomplete(autocomplete.Select2QuerySetView):
|
|||||||
if not self.request.user.is_authenticated:
|
if not self.request.user.is_authenticated:
|
||||||
return User.objects.none()
|
return User.objects.none()
|
||||||
|
|
||||||
qs = User.objects.all()
|
qs = User.objects.filter(PermissionBackend.filter_queryset(self.request.user, User, "view")).all()
|
||||||
|
|
||||||
if self.q:
|
if self.q:
|
||||||
qs = qs.filter(username__regex="^" + self.q)
|
qs = qs.filter(username__regex="^" + self.q)
|
||||||
@ -328,11 +341,17 @@ class ClubListView(LoginRequiredMixin, SingleTableView):
|
|||||||
model = Club
|
model = Club
|
||||||
table_class = ClubTable
|
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):
|
class ClubDetailView(LoginRequiredMixin, DetailView):
|
||||||
model = Club
|
model = Club
|
||||||
context_object_name = "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):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
club = context["club"]
|
club = context["club"]
|
||||||
@ -351,6 +370,11 @@ class ClubAddMemberView(LoginRequiredMixin, CreateView):
|
|||||||
form_class = MembershipForm
|
form_class = MembershipForm
|
||||||
template_name = 'member/add_members.html'
|
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):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context['formset'] = MemberFormSet()
|
context['formset'] = MemberFormSet()
|
||||||
|
@ -8,7 +8,7 @@ from polymorphic.admin import PolymorphicChildModelAdmin, \
|
|||||||
|
|
||||||
from .models.notes import Alias, Note, NoteClub, NoteSpecial, NoteUser
|
from .models.notes import Alias, Note, NoteClub, NoteSpecial, NoteUser
|
||||||
from .models.transactions import Transaction, TemplateCategory, TransactionTemplate, \
|
from .models.transactions import Transaction, TemplateCategory, TransactionTemplate, \
|
||||||
TemplateTransaction, MembershipTransaction
|
RecurrentTransaction, MembershipTransaction
|
||||||
|
|
||||||
|
|
||||||
class AliasInlines(admin.TabularInline):
|
class AliasInlines(admin.TabularInline):
|
||||||
@ -102,7 +102,7 @@ class TransactionAdmin(PolymorphicParentModelAdmin):
|
|||||||
"""
|
"""
|
||||||
Admin customisation for Transaction
|
Admin customisation for Transaction
|
||||||
"""
|
"""
|
||||||
child_models = (TemplateTransaction, MembershipTransaction)
|
child_models = (RecurrentTransaction, MembershipTransaction)
|
||||||
list_display = ('created_at', 'poly_source', 'poly_destination',
|
list_display = ('created_at', 'poly_source', 'poly_destination',
|
||||||
'quantity', 'amount', 'valid')
|
'quantity', 'amount', 'valid')
|
||||||
list_filter = ('valid',)
|
list_filter = ('valid',)
|
||||||
|
@ -6,7 +6,7 @@ from rest_polymorphic.serializers import PolymorphicSerializer
|
|||||||
|
|
||||||
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias
|
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias
|
||||||
from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory, \
|
from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory, \
|
||||||
TemplateTransaction
|
RecurrentTransaction, SpecialTransaction
|
||||||
|
|
||||||
|
|
||||||
class NoteSerializer(serializers.ModelSerializer):
|
class NoteSerializer(serializers.ModelSerializer):
|
||||||
@ -18,12 +18,7 @@ class NoteSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Note
|
model = Note
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
extra_kwargs = {
|
read_only_fields = [f.name for f in model._meta.get_fields()] # Notes are read-only protected
|
||||||
'url': {
|
|
||||||
'view_name': 'project-detail',
|
|
||||||
'lookup_field': 'pk'
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class NoteClubSerializer(serializers.ModelSerializer):
|
class NoteClubSerializer(serializers.ModelSerializer):
|
||||||
@ -31,10 +26,15 @@ class NoteClubSerializer(serializers.ModelSerializer):
|
|||||||
REST API Serializer for Club's notes.
|
REST API Serializer for Club's notes.
|
||||||
The djangorestframework plugin will analyse the model `NoteClub` and parse all fields in the API.
|
The djangorestframework plugin will analyse the model `NoteClub` and parse all fields in the API.
|
||||||
"""
|
"""
|
||||||
|
name = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = NoteClub
|
model = NoteClub
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
read_only_fields = ('note', 'club', )
|
||||||
|
|
||||||
|
def get_name(self, obj):
|
||||||
|
return str(obj)
|
||||||
|
|
||||||
|
|
||||||
class NoteSpecialSerializer(serializers.ModelSerializer):
|
class NoteSpecialSerializer(serializers.ModelSerializer):
|
||||||
@ -42,10 +42,15 @@ class NoteSpecialSerializer(serializers.ModelSerializer):
|
|||||||
REST API Serializer for special notes.
|
REST API Serializer for special notes.
|
||||||
The djangorestframework plugin will analyse the model `NoteSpecial` and parse all fields in the API.
|
The djangorestframework plugin will analyse the model `NoteSpecial` and parse all fields in the API.
|
||||||
"""
|
"""
|
||||||
|
name = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = NoteSpecial
|
model = NoteSpecial
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
read_only_fields = ('note', )
|
||||||
|
|
||||||
|
def get_name(self, obj):
|
||||||
|
return str(obj)
|
||||||
|
|
||||||
|
|
||||||
class NoteUserSerializer(serializers.ModelSerializer):
|
class NoteUserSerializer(serializers.ModelSerializer):
|
||||||
@ -53,10 +58,15 @@ class NoteUserSerializer(serializers.ModelSerializer):
|
|||||||
REST API Serializer for User's notes.
|
REST API Serializer for User's notes.
|
||||||
The djangorestframework plugin will analyse the model `NoteUser` and parse all fields in the API.
|
The djangorestframework plugin will analyse the model `NoteUser` and parse all fields in the API.
|
||||||
"""
|
"""
|
||||||
|
name = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = NoteUser
|
model = NoteUser
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
read_only_fields = ('note', 'user', )
|
||||||
|
|
||||||
|
def get_name(self, obj):
|
||||||
|
return str(obj)
|
||||||
|
|
||||||
|
|
||||||
class AliasSerializer(serializers.ModelSerializer):
|
class AliasSerializer(serializers.ModelSerializer):
|
||||||
@ -68,6 +78,7 @@ class AliasSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Alias
|
model = Alias
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
read_only_fields = ('note', )
|
||||||
|
|
||||||
|
|
||||||
class NotePolymorphicSerializer(PolymorphicSerializer):
|
class NotePolymorphicSerializer(PolymorphicSerializer):
|
||||||
@ -78,6 +89,9 @@ class NotePolymorphicSerializer(PolymorphicSerializer):
|
|||||||
NoteSpecial: NoteSpecialSerializer
|
NoteSpecial: NoteSpecialSerializer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Note
|
||||||
|
|
||||||
|
|
||||||
class TemplateCategorySerializer(serializers.ModelSerializer):
|
class TemplateCategorySerializer(serializers.ModelSerializer):
|
||||||
"""
|
"""
|
||||||
@ -112,14 +126,14 @@ class TransactionSerializer(serializers.ModelSerializer):
|
|||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
class TemplateTransactionSerializer(serializers.ModelSerializer):
|
class RecurrentTransactionSerializer(serializers.ModelSerializer):
|
||||||
"""
|
"""
|
||||||
REST API Serializer for Transactions.
|
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:
|
class Meta:
|
||||||
model = TemplateTransaction
|
model = RecurrentTransaction
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
@ -134,9 +148,24 @@ class MembershipTransactionSerializer(serializers.ModelSerializer):
|
|||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class SpecialTransactionSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
REST API Serializer for Special transactions.
|
||||||
|
The djangorestframework plugin will analyse the model `SpecialTransaction` and parse all fields in the API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = SpecialTransaction
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
class TransactionPolymorphicSerializer(PolymorphicSerializer):
|
class TransactionPolymorphicSerializer(PolymorphicSerializer):
|
||||||
model_serializer_mapping = {
|
model_serializer_mapping = {
|
||||||
Transaction: TransactionSerializer,
|
Transaction: TransactionSerializer,
|
||||||
TemplateTransaction: TemplateTransactionSerializer,
|
RecurrentTransaction: RecurrentTransactionSerializer,
|
||||||
MembershipTransaction: MembershipTransactionSerializer,
|
MembershipTransaction: MembershipTransactionSerializer,
|
||||||
|
SpecialTransaction: SpecialTransactionSerializer,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Transaction
|
||||||
|
@ -3,57 +3,16 @@
|
|||||||
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from rest_framework import viewsets
|
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||||
from rest_framework.filters import SearchFilter
|
from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet
|
||||||
|
|
||||||
from .serializers import NoteSerializer, NotePolymorphicSerializer, NoteClubSerializer, NoteSpecialSerializer, \
|
from .serializers import NotePolymorphicSerializer, AliasSerializer, TemplateCategorySerializer, \
|
||||||
NoteUserSerializer, AliasSerializer, \
|
TransactionTemplateSerializer, TransactionPolymorphicSerializer
|
||||||
TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer
|
from ..models.notes import Note, Alias
|
||||||
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias
|
|
||||||
from ..models.transactions import TransactionTemplate, Transaction, TemplateCategory
|
from ..models.transactions import TransactionTemplate, Transaction, TemplateCategory
|
||||||
|
|
||||||
|
|
||||||
class NoteViewSet(viewsets.ModelViewSet):
|
class NotePolymorphicViewSet(ReadOnlyProtectedModelViewSet):
|
||||||
"""
|
|
||||||
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):
|
|
||||||
"""
|
"""
|
||||||
REST API View set.
|
REST API View set.
|
||||||
The djangorestframework plugin will get all `Note` objects (with polymorhism), serialize it to JSON with the given serializer,
|
The djangorestframework plugin will get all `Note` objects (with polymorhism), serialize it to JSON with the given serializer,
|
||||||
@ -61,36 +20,27 @@ class NotePolymorphicViewSet(viewsets.ModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = Note.objects.all()
|
queryset = Note.objects.all()
|
||||||
serializer_class = NotePolymorphicSerializer
|
serializer_class = NotePolymorphicSerializer
|
||||||
|
filter_backends = [SearchFilter, OrderingFilter]
|
||||||
|
search_fields = ['$alias__normalized_name', '$alias__name', '$polymorphic_ctype__model', ]
|
||||||
|
ordering_fields = ['alias__name', 'alias__normalized_name']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""
|
"""
|
||||||
Parse query and apply filters.
|
Parse query and apply filters.
|
||||||
:return: The filtered set of requested notes
|
:return: The filtered set of requested notes
|
||||||
"""
|
"""
|
||||||
queryset = Note.objects.all()
|
queryset = super().get_queryset()
|
||||||
|
|
||||||
alias = self.request.query_params.get("alias", ".*")
|
alias = self.request.query_params.get("alias", ".*")
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
Q(alias__name__regex="^" + alias)
|
Q(alias__name__regex="^" + alias)
|
||||||
|
| Q(alias__normalized_name__regex="^" + Alias.normalize(alias))
|
||||||
| Q(alias__normalized_name__regex="^" + alias.lower()))
|
| Q(alias__normalized_name__regex="^" + alias.lower()))
|
||||||
|
|
||||||
note_type = self.request.query_params.get("type", None)
|
return queryset.distinct()
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class AliasViewSet(viewsets.ModelViewSet):
|
class AliasViewSet(ReadProtectedModelViewSet):
|
||||||
"""
|
"""
|
||||||
REST API View set.
|
REST API View set.
|
||||||
The djangorestframework plugin will get all `Alias` objects, serialize it to JSON with the given serializer,
|
The djangorestframework plugin will get all `Alias` objects, serialize it to JSON with the given serializer,
|
||||||
@ -98,6 +48,9 @@ class AliasViewSet(viewsets.ModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = Alias.objects.all()
|
queryset = Alias.objects.all()
|
||||||
serializer_class = AliasSerializer
|
serializer_class = AliasSerializer
|
||||||
|
filter_backends = [SearchFilter, OrderingFilter]
|
||||||
|
search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
|
||||||
|
ordering_fields = ['name', 'normalized_name']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""
|
"""
|
||||||
@ -105,35 +58,18 @@ class AliasViewSet(viewsets.ModelViewSet):
|
|||||||
:return: The filtered set of requested aliases
|
:return: The filtered set of requested aliases
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = Alias.objects.all()
|
queryset = super().get_queryset()
|
||||||
|
|
||||||
alias = self.request.query_params.get("alias", ".*")
|
alias = self.request.query_params.get("alias", ".*")
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
Q(name__regex="^" + alias) | Q(normalized_name__regex="^" + alias.lower()))
|
Q(name__regex="^" + alias)
|
||||||
|
| Q(normalized_name__regex="^" + Alias.normalize(alias))
|
||||||
note_id = self.request.query_params.get("note", None)
|
| Q(normalized_name__regex="^" + alias.lower()))
|
||||||
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()
|
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
class TemplateCategoryViewSet(viewsets.ModelViewSet):
|
class TemplateCategoryViewSet(ReadProtectedModelViewSet):
|
||||||
"""
|
"""
|
||||||
REST API View set.
|
REST API View set.
|
||||||
The djangorestframework plugin will get all `TemplateCategory` objects, serialize it to JSON with the given serializer,
|
The djangorestframework plugin will get all `TemplateCategory` objects, serialize it to JSON with the given serializer,
|
||||||
@ -145,7 +81,7 @@ class TemplateCategoryViewSet(viewsets.ModelViewSet):
|
|||||||
search_fields = ['$name', ]
|
search_fields = ['$name', ]
|
||||||
|
|
||||||
|
|
||||||
class TransactionTemplateViewSet(viewsets.ModelViewSet):
|
class TransactionTemplateViewSet(ReadProtectedModelViewSet):
|
||||||
"""
|
"""
|
||||||
REST API View set.
|
REST API View set.
|
||||||
The djangorestframework plugin will get all `TransactionTemplate` objects, serialize it to JSON with the given serializer,
|
The djangorestframework plugin will get all `TransactionTemplate` objects, serialize it to JSON with the given serializer,
|
||||||
@ -157,7 +93,7 @@ class TransactionTemplateViewSet(viewsets.ModelViewSet):
|
|||||||
filterset_fields = ['name', 'amount', 'display', 'category', ]
|
filterset_fields = ['name', 'amount', 'display', 'category', ]
|
||||||
|
|
||||||
|
|
||||||
class TransactionViewSet(viewsets.ModelViewSet):
|
class TransactionViewSet(ReadProtectedModelViewSet):
|
||||||
"""
|
"""
|
||||||
REST API View set.
|
REST API View set.
|
||||||
The djangorestframework plugin will get all `Transaction` objects, serialize it to JSON with the given serializer,
|
The djangorestframework plugin will get all `Transaction` objects, serialize it to JSON with the given serializer,
|
||||||
|
@ -6,7 +6,7 @@ from django import forms
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from .models import Alias
|
from .models import Alias
|
||||||
from .models import Transaction, TransactionTemplate
|
from .models import TransactionTemplate
|
||||||
|
|
||||||
|
|
||||||
class AliasForm(forms.ModelForm):
|
class AliasForm(forms.ModelForm):
|
||||||
@ -50,52 +50,3 @@ class TransactionTemplateForm(forms.ModelForm):
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class TransactionForm(forms.ModelForm):
|
|
||||||
def save(self, commit=True):
|
|
||||||
super().save(commit)
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
"""
|
|
||||||
If the user has no right to transfer funds, then it will be the source of the transfer by default.
|
|
||||||
Transactions between a note and the same note are not authorized.
|
|
||||||
"""
|
|
||||||
|
|
||||||
cleaned_data = super().clean()
|
|
||||||
if "source" not in cleaned_data: # TODO Replace it with "if %user has no right to transfer funds"
|
|
||||||
cleaned_data["source"] = self.user.note
|
|
||||||
|
|
||||||
if cleaned_data["source"].pk == cleaned_data["destination"].pk:
|
|
||||||
self.add_error("destination", _("Source and destination must be different."))
|
|
||||||
|
|
||||||
return cleaned_data
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Transaction
|
|
||||||
fields = (
|
|
||||||
'source',
|
|
||||||
'destination',
|
|
||||||
'reason',
|
|
||||||
'amount',
|
|
||||||
)
|
|
||||||
|
|
||||||
# Voir ci-dessus
|
|
||||||
widgets = {
|
|
||||||
'source':
|
|
||||||
autocomplete.ModelSelect2(
|
|
||||||
url='note:note_autocomplete',
|
|
||||||
attrs={
|
|
||||||
'data-placeholder': 'Note ...',
|
|
||||||
'data-minimum-input-length': 1,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
'destination':
|
|
||||||
autocomplete.ModelSelect2(
|
|
||||||
url='note:note_autocomplete',
|
|
||||||
attrs={
|
|
||||||
'data-placeholder': 'Note ...',
|
|
||||||
'data-minimum-input-length': 1,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
@ -3,12 +3,12 @@
|
|||||||
|
|
||||||
from .notes import Alias, Note, NoteClub, NoteSpecial, NoteUser
|
from .notes import Alias, Note, NoteClub, NoteSpecial, NoteUser
|
||||||
from .transactions import MembershipTransaction, Transaction, \
|
from .transactions import MembershipTransaction, Transaction, \
|
||||||
TemplateCategory, TransactionTemplate, TemplateTransaction
|
TemplateCategory, TransactionTemplate, RecurrentTransaction
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Notes
|
# Notes
|
||||||
'Alias', 'Note', 'NoteClub', 'NoteSpecial', 'NoteUser',
|
'Alias', 'Note', 'NoteClub', 'NoteSpecial', 'NoteUser',
|
||||||
# Transactions
|
# Transactions
|
||||||
'MembershipTransaction', 'Transaction', 'TemplateCategory', 'TransactionTemplate',
|
'MembershipTransaction', 'Transaction', 'TemplateCategory', 'TransactionTemplate',
|
||||||
'TemplateTransaction',
|
'RecurrentTransaction',
|
||||||
]
|
]
|
||||||
|
@ -7,7 +7,7 @@ from django.utils import timezone
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from polymorphic.models import PolymorphicModel
|
from polymorphic.models import PolymorphicModel
|
||||||
|
|
||||||
from .notes import Note, NoteClub
|
from .notes import Note, NoteClub, NoteSpecial
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Defines transactions
|
Defines transactions
|
||||||
@ -68,6 +68,7 @@ class TransactionTemplate(models.Model):
|
|||||||
description = models.CharField(
|
description = models.CharField(
|
||||||
verbose_name=_('description'),
|
verbose_name=_('description'),
|
||||||
max_length=255,
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -106,7 +107,10 @@ class Transaction(PolymorphicModel):
|
|||||||
verbose_name=_('quantity'),
|
verbose_name=_('quantity'),
|
||||||
default=1,
|
default=1,
|
||||||
)
|
)
|
||||||
amount = models.PositiveIntegerField(verbose_name=_('amount'), )
|
amount = models.PositiveIntegerField(
|
||||||
|
verbose_name=_('amount'),
|
||||||
|
)
|
||||||
|
|
||||||
reason = models.CharField(
|
reason = models.CharField(
|
||||||
verbose_name=_('reason'),
|
verbose_name=_('reason'),
|
||||||
max_length=255,
|
max_length=255,
|
||||||
@ -132,6 +136,7 @@ class Transaction(PolymorphicModel):
|
|||||||
|
|
||||||
if self.source.pk == self.destination.pk:
|
if self.source.pk == self.destination.pk:
|
||||||
# When source == destination, no money is transfered
|
# When source == destination, no money is transfered
|
||||||
|
super().save(*args, **kwargs)
|
||||||
return
|
return
|
||||||
|
|
||||||
created = self.pk is None
|
created = self.pk is None
|
||||||
@ -147,20 +152,25 @@ class Transaction(PolymorphicModel):
|
|||||||
self.source.balance -= to_transfer
|
self.source.balance -= to_transfer
|
||||||
self.destination.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
|
# Save notes
|
||||||
self.source.save()
|
self.source.save()
|
||||||
self.destination.save()
|
self.destination.save()
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total(self):
|
def total(self):
|
||||||
return self.amount * self.quantity
|
return self.amount * self.quantity
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type(self):
|
||||||
|
return _('Transfer')
|
||||||
|
|
||||||
class TemplateTransaction(Transaction):
|
|
||||||
|
class RecurrentTransaction(Transaction):
|
||||||
"""
|
"""
|
||||||
Special type of :model:`note.Transaction` associated to a :model:`note.TransactionTemplate`.
|
Special type of :model:`note.Transaction` associated to a :model:`note.TransactionTemplate`.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
template = models.ForeignKey(
|
template = models.ForeignKey(
|
||||||
@ -173,6 +183,36 @@ class TemplateTransaction(Transaction):
|
|||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type(self):
|
||||||
|
return _('Template')
|
||||||
|
|
||||||
|
|
||||||
|
class SpecialTransaction(Transaction):
|
||||||
|
"""
|
||||||
|
Special type of :model:`note.Transaction` associated to transactions with special notes
|
||||||
|
"""
|
||||||
|
|
||||||
|
last_name = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_("name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
first_name = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_("first_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
bank = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_("bank"),
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type(self):
|
||||||
|
return _('Credit') if isinstance(self.source, NoteSpecial) else _("Debit")
|
||||||
|
|
||||||
|
|
||||||
class MembershipTransaction(Transaction):
|
class MembershipTransaction(Transaction):
|
||||||
"""
|
"""
|
||||||
@ -189,3 +229,7 @@ class MembershipTransaction(Transaction):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("membership transaction")
|
verbose_name = _("membership transaction")
|
||||||
verbose_name_plural = _("membership transactions")
|
verbose_name_plural = _("membership transactions")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type(self):
|
||||||
|
return _('membership transaction')
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
import html
|
||||||
|
|
||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
from django_tables2.utils import A
|
from django_tables2.utils import A
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from .models.notes import Alias
|
from .models.notes import Alias
|
||||||
from .models.transactions import Transaction
|
from .models.transactions import Transaction
|
||||||
@ -17,17 +20,25 @@ class HistoryTable(tables.Table):
|
|||||||
'table table-condensed table-striped table-hover'
|
'table table-condensed table-striped table-hover'
|
||||||
}
|
}
|
||||||
model = Transaction
|
model = Transaction
|
||||||
exclude = ("polymorphic_ctype", )
|
exclude = ("id", "polymorphic_ctype", )
|
||||||
template_name = 'django_tables2/bootstrap4.html'
|
template_name = 'django_tables2/bootstrap4.html'
|
||||||
sequence = ('...', 'total', 'valid')
|
sequence = ('...', 'type', 'total', 'valid', )
|
||||||
|
orderable = False
|
||||||
|
|
||||||
|
type = tables.Column()
|
||||||
|
|
||||||
total = tables.Column() # will use Transaction.total() !!
|
total = tables.Column() # will use Transaction.total() !!
|
||||||
|
|
||||||
|
valid = tables.Column(attrs={"td": {"id": lambda record: "validate_" + str(record.id),
|
||||||
|
"class": lambda record: str(record.valid).lower() + ' validate',
|
||||||
|
"onclick": lambda record: 'de_validate(' + str(record.id) + ', '
|
||||||
|
+ str(record.valid).lower() + ')'}})
|
||||||
|
|
||||||
def order_total(self, queryset, is_descending):
|
def order_total(self, queryset, is_descending):
|
||||||
# needed for rendering
|
# needed for rendering
|
||||||
queryset = queryset.annotate(total=F('amount') * F('quantity')) \
|
queryset = queryset.annotate(total=F('amount') * F('quantity')) \
|
||||||
.order_by(('-' if is_descending else '') + 'total')
|
.order_by(('-' if is_descending else '') + 'total')
|
||||||
return (queryset, True)
|
return queryset, True
|
||||||
|
|
||||||
def render_amount(self, value):
|
def render_amount(self, value):
|
||||||
return pretty_money(value)
|
return pretty_money(value)
|
||||||
@ -35,6 +46,16 @@ class HistoryTable(tables.Table):
|
|||||||
def render_total(self, value):
|
def render_total(self, value):
|
||||||
return pretty_money(value)
|
return pretty_money(value)
|
||||||
|
|
||||||
|
def render_type(self, value):
|
||||||
|
return _(value)
|
||||||
|
|
||||||
|
# Django-tables escape strings. That's a wrong thing.
|
||||||
|
def render_reason(self, value):
|
||||||
|
return html.unescape(value)
|
||||||
|
|
||||||
|
def render_valid(self, value):
|
||||||
|
return "✔" if value else "✖"
|
||||||
|
|
||||||
|
|
||||||
class AliasTable(tables.Table):
|
class AliasTable(tables.Table):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -11,7 +11,7 @@ def pretty_money(value):
|
|||||||
abs(value) // 100,
|
abs(value) // 100,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return "{:s}{:d} € {:02d}".format(
|
return "{:s}{:d}.{:02d} €".format(
|
||||||
"- " if value < 0 else "",
|
"- " if value < 0 else "",
|
||||||
abs(value) // 100,
|
abs(value) // 100,
|
||||||
abs(value) % 100,
|
abs(value) % 100,
|
||||||
|
@ -3,53 +3,46 @@
|
|||||||
|
|
||||||
from dal import autocomplete
|
from dal import autocomplete
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import CreateView, ListView, UpdateView
|
from django.views.generic import CreateView, ListView, UpdateView
|
||||||
from django_tables2 import SingleTableView
|
from django_tables2 import SingleTableView
|
||||||
|
from permission.backends import PermissionBackend
|
||||||
|
|
||||||
from .forms import TransactionForm, TransactionTemplateForm
|
from .forms import TransactionTemplateForm
|
||||||
from .models import Transaction, TransactionTemplate, Alias
|
from .models import Transaction, TransactionTemplate, Alias, RecurrentTransaction, NoteSpecial
|
||||||
|
from .models.transactions import SpecialTransaction
|
||||||
from .tables import HistoryTable
|
from .tables import HistoryTable
|
||||||
|
|
||||||
|
|
||||||
class TransactionCreate(LoginRequiredMixin, CreateView):
|
class TransactionCreate(LoginRequiredMixin, SingleTableView):
|
||||||
"""
|
"""
|
||||||
Show transfer page
|
Show transfer page
|
||||||
|
|
||||||
TODO: If user have sufficient rights, they can transfer from an other note
|
|
||||||
"""
|
"""
|
||||||
model = Transaction
|
template_name = "note/transaction_form.html"
|
||||||
form_class = TransactionForm
|
|
||||||
|
# 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):
|
def get_context_data(self, **kwargs):
|
||||||
"""
|
"""
|
||||||
Add some context variables in template such as page title
|
Add some context variables in template such as page title
|
||||||
"""
|
"""
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context['title'] = _('Transfer money from your account '
|
context['title'] = _('Transfer money')
|
||||||
'to one or others')
|
context['polymorphic_ctype'] = ContentType.objects.get_for_model(Transaction).pk
|
||||||
|
context['special_polymorphic_ctype'] = ContentType.objects.get_for_model(SpecialTransaction).pk
|
||||||
context['no_cache'] = True
|
context['special_types'] = NoteSpecial.objects.order_by("special_type").all()
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def get_form(self, form_class=None):
|
|
||||||
"""
|
|
||||||
If the user has no right to transfer funds, then it won't have the choice of the source of the transfer.
|
|
||||||
"""
|
|
||||||
form = super().get_form(form_class)
|
|
||||||
|
|
||||||
if False: # TODO: fix it with "if %user has no right to transfer funds"
|
|
||||||
del form.fields['source']
|
|
||||||
form.user = self.request.user
|
|
||||||
|
|
||||||
return form
|
|
||||||
|
|
||||||
def get_success_url(self):
|
|
||||||
return reverse('note:transfer')
|
|
||||||
|
|
||||||
|
|
||||||
class NoteAutocomplete(autocomplete.Select2QuerySetView):
|
class NoteAutocomplete(autocomplete.Select2QuerySetView):
|
||||||
"""
|
"""
|
||||||
@ -127,21 +120,30 @@ class ConsoView(LoginRequiredMixin, SingleTableView):
|
|||||||
"""
|
"""
|
||||||
Consume
|
Consume
|
||||||
"""
|
"""
|
||||||
model = Transaction
|
|
||||||
template_name = "note/conso_form.html"
|
template_name = "note/conso_form.html"
|
||||||
|
|
||||||
# Transaction history table
|
# Transaction history table
|
||||||
table_class = HistoryTable
|
table_class = HistoryTable
|
||||||
table_pagination = {"per_page": 10}
|
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):
|
def get_context_data(self, **kwargs):
|
||||||
"""
|
"""
|
||||||
Add some context variables in template such as page title
|
Add some context variables in template such as page title
|
||||||
"""
|
"""
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context['transaction_templates'] = TransactionTemplate.objects.filter(display=True) \
|
from django.db.models import Count
|
||||||
.order_by('category')
|
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['title'] = _("Consumptions")
|
||||||
|
context['polymorphic_ctype'] = ContentType.objects.get_for_model(RecurrentTransaction).pk
|
||||||
|
|
||||||
# select2 compatibility
|
# select2 compatibility
|
||||||
context['no_cache'] = True
|
context['no_cache'] = True
|
||||||
|
4
apps/permission/__init__.py
Normal file
4
apps/permission/__init__.py
Normal 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
31
apps/permission/admin.py
Normal 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', )
|
||||||
|
|
0
apps/permission/api/__init__.py
Normal file
0
apps/permission/api/__init__.py
Normal file
17
apps/permission/api/serializers.py
Normal file
17
apps/permission/api/serializers.py
Normal 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__'
|
11
apps/permission/api/urls.py
Normal file
11
apps/permission/api/urls.py
Normal 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)
|
20
apps/permission/api/views.py
Normal file
20
apps/permission/api/views.py
Normal 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
14
apps/permission/apps.py
Normal 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
116
apps/permission/backends.py
Normal 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"))
|
554
apps/permission/fixtures/initial.json
Normal file
554
apps/permission/fixtures/initial.json
Normal 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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
0
apps/permission/migrations/__init__.py
Normal file
0
apps/permission/migrations/__init__.py
Normal file
284
apps/permission/models.py
Normal file
284
apps/permission/models.py
Normal 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)
|
||||||
|
|
63
apps/permission/permissions.py
Normal file
63
apps/permission/permissions.py
Normal 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
106
apps/permission/signals.py
Normal 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
|
0
apps/permission/templatetags/__init__.py
Normal file
0
apps/permission/templatetags/__init__.py
Normal file
55
apps/permission/templatetags/perms.py
Normal file
55
apps/permission/templatetags/perms.py
Normal 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)
|
@ -1 +0,0 @@
|
|||||||
Subproject commit 123466cfa914422422cd372197e64adf65ef05f7
|
|
@ -7,7 +7,7 @@ if [ -z ${NOTE_URL+x} ]; then
|
|||||||
else
|
else
|
||||||
sed -i -e "s/example.com/$DOMAIN/g" /code/apps/member/fixtures/initial.json
|
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/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
|
sed -i -e "s/REPLACEME/La Note Kfet \\\\ud83c\\\\udf7b/g" /code/note_kfet/fixtures/cas.json
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
@ -25,7 +25,8 @@ msgstr ""
|
|||||||
#: apps/activity/models.py:19 apps/activity/models.py:44
|
#: apps/activity/models.py:19 apps/activity/models.py:44
|
||||||
#: apps/member/models.py:61 apps/member/models.py:112
|
#: apps/member/models.py:61 apps/member/models.py:112
|
||||||
#: apps/note/models/notes.py:188 apps/note/models/transactions.py:24
|
#: apps/note/models/notes.py:188 apps/note/models/transactions.py:24
|
||||||
#: apps/note/models/transactions.py:44 templates/member/profile_detail.html:15
|
#: apps/note/models/transactions.py:44 apps/note/models/transactions.py:202
|
||||||
|
#: templates/member/profile_detail.html:15
|
||||||
msgid "name"
|
msgid "name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -51,7 +52,7 @@ msgid "description"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/activity/models.py:54 apps/note/models/notes.py:164
|
#: apps/activity/models.py:54 apps/note/models/notes.py:164
|
||||||
#: apps/note/models/transactions.py:62
|
#: apps/note/models/transactions.py:62 apps/note/models/transactions.py:115
|
||||||
msgid "type"
|
msgid "type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -254,12 +255,12 @@ msgstr ""
|
|||||||
msgid "Alias successfully deleted"
|
msgid "Alias successfully deleted"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/note/admin.py:120 apps/note/models/transactions.py:93
|
#: apps/note/admin.py:120 apps/note/models/transactions.py:94
|
||||||
msgid "source"
|
msgid "source"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/note/admin.py:128 apps/note/admin.py:156
|
#: apps/note/admin.py:128 apps/note/admin.py:156
|
||||||
#: apps/note/models/transactions.py:53 apps/note/models/transactions.py:99
|
#: apps/note/models/transactions.py:53 apps/note/models/transactions.py:100
|
||||||
msgid "destination"
|
msgid "destination"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -279,10 +280,6 @@ msgstr ""
|
|||||||
msgid "Maximal size: 2MB"
|
msgid "Maximal size: 2MB"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/note/forms.py:70
|
|
||||||
msgid "Source and destination must be different."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: apps/note/models/notes.py:27
|
#: apps/note/models/notes.py:27
|
||||||
msgid "account balance"
|
msgid "account balance"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -313,7 +310,7 @@ msgstr ""
|
|||||||
msgid "display image"
|
msgid "display image"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/note/models/notes.py:53 apps/note/models/transactions.py:102
|
#: apps/note/models/notes.py:53 apps/note/models/transactions.py:103
|
||||||
msgid "created at"
|
msgid "created at"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -399,7 +396,7 @@ msgstr ""
|
|||||||
msgid "A template with this name already exist"
|
msgid "A template with this name already exist"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/note/models/transactions.py:56 apps/note/models/transactions.py:109
|
#: apps/note/models/transactions.py:56 apps/note/models/transactions.py:111
|
||||||
msgid "amount"
|
msgid "amount"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -407,31 +404,57 @@ msgstr ""
|
|||||||
msgid "in centimes"
|
msgid "in centimes"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/note/models/transactions.py:74
|
#: apps/note/models/transactions.py:75
|
||||||
msgid "transaction template"
|
msgid "transaction template"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/note/models/transactions.py:75
|
#: apps/note/models/transactions.py:76
|
||||||
msgid "transaction templates"
|
msgid "transaction templates"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/note/models/transactions.py:106
|
#: apps/note/models/transactions.py:107
|
||||||
msgid "quantity"
|
msgid "quantity"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/note/models/transactions.py:111
|
#: apps/note/models/transactions.py:117 templates/note/transaction_form.html:15
|
||||||
|
msgid "Gift"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: apps/note/models/transactions.py:118 templates/base.html:90
|
||||||
|
#: templates/note/transaction_form.html:19
|
||||||
|
#: templates/note/transaction_form.html:126
|
||||||
|
msgid "Transfer"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: apps/note/models/transactions.py:119
|
||||||
|
msgid "Template"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: apps/note/models/transactions.py:120 templates/note/transaction_form.html:23
|
||||||
|
msgid "Credit"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: apps/note/models/transactions.py:121 templates/note/transaction_form.html:27
|
||||||
|
msgid "Debit"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: apps/note/models/transactions.py:122 apps/note/models/transactions.py:230
|
||||||
|
msgid "membership transaction"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: apps/note/models/transactions.py:129
|
||||||
msgid "reason"
|
msgid "reason"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/note/models/transactions.py:115
|
#: apps/note/models/transactions.py:133
|
||||||
msgid "valid"
|
msgid "valid"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/note/models/transactions.py:120
|
#: apps/note/models/transactions.py:138
|
||||||
msgid "transaction"
|
msgid "transaction"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/note/models/transactions.py:121
|
#: apps/note/models/transactions.py:139
|
||||||
msgid "transactions"
|
msgid "transactions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -439,12 +462,21 @@ msgstr ""
|
|||||||
msgid "membership transaction"
|
msgid "membership transaction"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: apps/note/models/transactions.py:207
|
||||||
|
msgid "first_name"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/note/models/transactions.py:191
|
#: apps/note/models/transactions.py:191
|
||||||
|
#: apps/note/models/transactions.py:212
|
||||||
|
msgid "bank"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: apps/note/models/transactions.py:231
|
||||||
msgid "membership transactions"
|
msgid "membership transactions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/note/views.py:31
|
#: apps/note/views.py:31
|
||||||
msgid "Transfer money from your account to one or others"
|
msgid "Transfer money"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/note/views.py:144 templates/base.html:70
|
#: apps/note/views.py:144 templates/base.html:70
|
||||||
@ -549,8 +581,8 @@ msgstr ""
|
|||||||
msgid "Unit price"
|
msgid "Unit price"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/treasury/tables.py:14
|
#: apps/note/views.py:132 templates/base.html:78
|
||||||
msgid "Billing #"
|
msgid "Consumptions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/treasury/tables.py:17
|
#: apps/treasury/tables.py:17
|
||||||
@ -580,11 +612,11 @@ msgstr ""
|
|||||||
msgid "The ENS Paris-Saclay BDE note."
|
msgid "The ENS Paris-Saclay BDE note."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/base.html:73
|
#: templates/base.html:81
|
||||||
msgid "Clubs"
|
msgid "Clubs"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/base.html:76
|
#: templates/base.html:84
|
||||||
msgid "Activities"
|
msgid "Activities"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -653,6 +685,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: templates/django_filters/rest_framework/form.html:5
|
#: templates/django_filters/rest_framework/form.html:5
|
||||||
#: templates/member/club_form.html:10 templates/treasury/billing_form.html:38
|
#: templates/member/club_form.html:10 templates/treasury/billing_form.html:38
|
||||||
|
#: templates/member/club_form.html:10
|
||||||
msgid "Submit"
|
msgid "Submit"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -737,6 +770,83 @@ msgstr ""
|
|||||||
msgid "Sign up"
|
msgid "Sign up"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: templates/note/conso_form.html:28 templates/note/transaction_form.html:38
|
||||||
|
msgid "Select emitters"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: templates/note/conso_form.html:45
|
||||||
|
msgid "Select consumptions"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: templates/note/conso_form.html:51
|
||||||
|
msgid "Consume!"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: templates/note/conso_form.html:64
|
||||||
|
msgid "Most used buttons"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: templates/note/conso_form.html:121
|
||||||
|
msgid "Edit"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: templates/note/conso_form.html:126
|
||||||
|
msgid "Single consumptions"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: templates/note/conso_form.html:130
|
||||||
|
msgid "Double consumptions"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: templates/note/conso_form.html:141
|
||||||
|
msgid "Recent transactions history"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: templates/note/transaction_form.html:55
|
||||||
|
msgid "External payment"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: templates/note/transaction_form.html:63
|
||||||
|
msgid "Transfer type"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: templates/note/transaction_form.html:73
|
||||||
|
msgid "Name"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: templates/note/transaction_form.html:79
|
||||||
|
msgid "First name"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: templates/note/transaction_form.html:85
|
||||||
|
msgid "Bank"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: templates/note/transaction_form.html:97
|
||||||
|
#: templates/note/transaction_form.html:179
|
||||||
|
#: templates/note/transaction_form.html:186
|
||||||
|
msgid "Select receivers"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: templates/note/transaction_form.html:114
|
||||||
|
msgid "Amount"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: templates/note/transaction_form.html:119
|
||||||
|
msgid "Reason"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: templates/note/transaction_form.html:193
|
||||||
|
msgid "Credit note"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: templates/note/transaction_form.html:200
|
||||||
|
msgid "Debit note"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Sign up"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: templates/note/transactiontemplate_form.html:6
|
#: templates/note/transactiontemplate_form.html:6
|
||||||
msgid "Buttons list"
|
msgid "Buttons list"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
@ -1,6 +1,66 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# 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):
|
class TurbolinksMiddleware(object):
|
||||||
"""
|
"""
|
||||||
|
@ -41,6 +41,8 @@ else:
|
|||||||
try:
|
try:
|
||||||
#in secrets.py defines everything you want
|
#in secrets.py defines everything you want
|
||||||
from .secrets import *
|
from .secrets import *
|
||||||
|
INSTALLED_APPS += OPTIONAL_APPS
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -74,7 +76,7 @@ if "cas" in INSTALLED_APPS:
|
|||||||
|
|
||||||
|
|
||||||
if "logs" in INSTALLED_APPS:
|
if "logs" in INSTALLED_APPS:
|
||||||
MIDDLEWARE += ('logs.middlewares.LogsMiddleware',)
|
MIDDLEWARE += ('note_kfet.middlewares.SessionMiddleware',)
|
||||||
|
|
||||||
if "debug_toolbar" in INSTALLED_APPS:
|
if "debug_toolbar" in INSTALLED_APPS:
|
||||||
MIDDLEWARE.insert(1, "debug_toolbar.middleware.DebugToolbarMiddleware")
|
MIDDLEWARE.insert(1, "debug_toolbar.middleware.DebugToolbarMiddleware")
|
||||||
|
@ -39,8 +39,6 @@ INSTALLED_APPS = [
|
|||||||
'polymorphic',
|
'polymorphic',
|
||||||
'crispy_forms',
|
'crispy_forms',
|
||||||
'django_tables2',
|
'django_tables2',
|
||||||
'cas_server',
|
|
||||||
'cas',
|
|
||||||
# Django contrib
|
# Django contrib
|
||||||
'django.contrib.admin',
|
'django.contrib.admin',
|
||||||
'django.contrib.admindocs',
|
'django.contrib.admindocs',
|
||||||
@ -62,6 +60,7 @@ INSTALLED_APPS = [
|
|||||||
'member',
|
'member',
|
||||||
'note',
|
'note',
|
||||||
'treasury',
|
'treasury',
|
||||||
|
'permission',
|
||||||
'api',
|
'api',
|
||||||
'logs',
|
'logs',
|
||||||
]
|
]
|
||||||
@ -127,18 +126,15 @@ PASSWORD_HASHERS = [
|
|||||||
'member.hashers.CustomNK15Hasher',
|
'member.hashers.CustomNK15Hasher',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Django Guardian object permissions
|
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = (
|
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 = {
|
REST_FRAMEWORK = {
|
||||||
# Use Django's standard `django.contrib.auth` permissions,
|
|
||||||
# or allow read-only access for unauthenticated users.
|
|
||||||
'DEFAULT_PERMISSION_CLASSES': [
|
'DEFAULT_PERMISSION_CLASSES': [
|
||||||
# TODO Maybe replace it with our custom permissions system
|
# Control API access with our role-based permission system
|
||||||
'rest_framework.permissions.DjangoModelPermissions',
|
'permission.permissions.StrongDjangoObjectPermissions',
|
||||||
],
|
],
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||||
'rest_framework.authentication.SessionAuthentication',
|
'rest_framework.authentication.SessionAuthentication',
|
||||||
|
@ -7,6 +7,8 @@ from django.contrib import admin
|
|||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from django.views.generic import RedirectView
|
from django.views.generic import RedirectView
|
||||||
|
|
||||||
|
from member.views import CustomLoginView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Dev so redirect to something random
|
# Dev so redirect to something random
|
||||||
path('', RedirectView.as_view(pattern_name='note:transfer'), name='index'),
|
path('', RedirectView.as_view(pattern_name='note:transfer'), name='index'),
|
||||||
@ -17,10 +19,11 @@ urlpatterns = [
|
|||||||
|
|
||||||
# Include Django Contrib and Core routers
|
# Include Django Contrib and Core routers
|
||||||
path('i18n/', include('django.conf.urls.i18n')),
|
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/doc/', include('django.contrib.admindocs.urls')),
|
||||||
path('admin/', admin.site.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')),
|
path('api/', include('api.urls')),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
djangorestframework==3.9.0
|
|
||||||
django-rest-polymorphic==0.1.8
|
|
||||||
|
|
@ -19,4 +19,6 @@ requests==2.22.0
|
|||||||
requests-oauthlib==1.2.0
|
requests-oauthlib==1.2.0
|
||||||
six==1.12.0
|
six==1.12.0
|
||||||
sqlparse==0.3.0
|
sqlparse==0.3.0
|
||||||
|
djangorestframework==3.9.0
|
||||||
|
django-rest-polymorphic==0.1.8
|
||||||
urllib3==1.25.3
|
urllib3==1.25.3
|
||||||
|
297
static/js/base.js
Normal file
297
static/js/base.js
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
// Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert balance in cents to a human readable amount
|
||||||
|
* @param value the balance, in cents
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function pretty_money(value) {
|
||||||
|
if (value % 100 === 0)
|
||||||
|
return (value < 0 ? "- " : "") + Math.floor(Math.abs(value) / 100) + " €";
|
||||||
|
else
|
||||||
|
return (value < 0 ? "- " : "") + Math.floor(Math.abs(value) / 100) + "."
|
||||||
|
+ (Math.abs(value) % 100 < 10 ? "0" : "") + (Math.abs(value) % 100) + " €";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a message on the top of the page.
|
||||||
|
* @param msg The message to display
|
||||||
|
* @param alert_type The type of the alert. Choices: info, success, warning, danger
|
||||||
|
*/
|
||||||
|
function addMsg(msg, alert_type) {
|
||||||
|
let msgDiv = $("#messages");
|
||||||
|
let html = msgDiv.html();
|
||||||
|
html += "<div class=\"alert alert-" + alert_type + " alert-dismissible\">" +
|
||||||
|
"<button class=\"close\" data-dismiss=\"alert\" href=\"#\"><span aria-hidden=\"true\">×</span></button>"
|
||||||
|
+ msg + "</div>\n";
|
||||||
|
msgDiv.html(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload the balance of the user on the right top corner
|
||||||
|
*/
|
||||||
|
function refreshBalance() {
|
||||||
|
$("#user_balance").load("/ #user_balance");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query the 20 first matched notes with a given pattern
|
||||||
|
* @param pattern The pattern that is queried
|
||||||
|
* @param fun For each found note with the matched alias `alias`, fun(note, alias) is called.
|
||||||
|
*/
|
||||||
|
function getMatchedNotes(pattern, fun) {
|
||||||
|
$.getJSON("/api/note/alias/?format=json&alias=" + pattern + "&search=user|club&ordering=normalized_name", fun);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a <li> entry with a given id and text
|
||||||
|
*/
|
||||||
|
function li(id, text) {
|
||||||
|
return "<li class=\"list-group-item py-1 d-flex justify-content-between align-items-center\"" +
|
||||||
|
" id=\"" + id + "\">" + text + "</li>\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render note name and picture
|
||||||
|
* @param note The note to render
|
||||||
|
* @param alias The alias to be displayed
|
||||||
|
* @param user_note_field
|
||||||
|
* @param profile_pic_field
|
||||||
|
*/
|
||||||
|
function displayNote(note, alias, user_note_field=null, profile_pic_field=null) {
|
||||||
|
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 (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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a note from the emitters.
|
||||||
|
* @param d The note to remove
|
||||||
|
* @param note_prefix The prefix of the identifiers of the <li> blocks of the emitters
|
||||||
|
* @param notes_display An array containing the infos of the buyers: [alias, note id, note object, quantity]
|
||||||
|
* @param note_list_id The div block identifier where the notes of the buyers are displayed
|
||||||
|
* @param user_note_field The identifier of the field that display the note of the hovered note (useful in
|
||||||
|
* consumptions, put null if not used)
|
||||||
|
* @param profile_pic_field The identifier of the field that display the profile picture of the hovered note
|
||||||
|
* (useful in consumptions, put null if not used)
|
||||||
|
* @returns an anonymous function to be compatible with jQuery events
|
||||||
|
*/
|
||||||
|
function removeNote(d, note_prefix="note", notes_display, note_list_id, user_note_field=null, profile_pic_field=null) {
|
||||||
|
return (function() {
|
||||||
|
let new_notes_display = [];
|
||||||
|
let html = "";
|
||||||
|
notes_display.forEach(function (disp) {
|
||||||
|
if (disp.quantity > 1 || disp.id !== d.id) {
|
||||||
|
disp.quantity -= disp.id === d.id ? 1 : 0;
|
||||||
|
new_notes_display.push(disp);
|
||||||
|
html += li(note_prefix + "_" + disp.id, disp.name
|
||||||
|
+ "<span class=\"badge badge-dark badge-pill\">" + disp.quantity + "</span>");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
notes_display.length = 0;
|
||||||
|
new_notes_display.forEach(function(disp) {
|
||||||
|
notes_display.push(disp);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#" + note_list_id).html(html);
|
||||||
|
notes_display.forEach(function (disp) {
|
||||||
|
let obj = $("#" + note_prefix + "_" + disp.id);
|
||||||
|
obj.click(removeNote(disp, note_prefix, notes_display, note_list_id, user_note_field, profile_pic_field));
|
||||||
|
obj.hover(function() {
|
||||||
|
if (disp.note)
|
||||||
|
displayNote(disp.note, disp.name, user_note_field, profile_pic_field);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an auto-complete field to query a note with its alias
|
||||||
|
* @param field_id The identifier of the text field where the alias is typed
|
||||||
|
* @param alias_matched_id The div block identifier where the matched aliases are displayed
|
||||||
|
* @param note_list_id The div block identifier where the notes of the buyers are displayed
|
||||||
|
* @param notes An array containing the note objects of the buyers
|
||||||
|
* @param notes_display An array containing the infos of the buyers: [alias, note id, note object, quantity]
|
||||||
|
* @param alias_prefix The prefix of the <li> blocks for the matched aliases
|
||||||
|
* @param note_prefix The prefix of the <li> blocks for the notes of the buyers
|
||||||
|
* @param user_note_field The identifier of the field that display the note of the hovered note (useful in
|
||||||
|
* consumptions, put null if not used)
|
||||||
|
* @param profile_pic_field The identifier of the field that display the profile picture of the hovered note
|
||||||
|
* (useful in consumptions, put null if not used)
|
||||||
|
* @param alias_click Function that is called when an alias is clicked. If this method exists and doesn't return true,
|
||||||
|
* the associated note is not displayed.
|
||||||
|
* Useful for a consumption if the item is selected before.
|
||||||
|
*/
|
||||||
|
function autoCompleteNote(field_id, alias_matched_id, note_list_id, notes, notes_display, alias_prefix="alias",
|
||||||
|
note_prefix="note", user_note_field=null, profile_pic_field=null, alias_click=null) {
|
||||||
|
let field = $("#" + field_id);
|
||||||
|
// When the user clicks on the search field, it is immediately cleared
|
||||||
|
field.click(function() {
|
||||||
|
field.val("");
|
||||||
|
});
|
||||||
|
|
||||||
|
let old_pattern = null;
|
||||||
|
|
||||||
|
// When the user type "Enter", the first alias is clicked
|
||||||
|
field.keypress(function(event) {
|
||||||
|
if (event.originalEvent.charCode === 13)
|
||||||
|
$("#" + alias_matched_id + " li").first().trigger("click");
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the user type something, the matched aliases are refreshed
|
||||||
|
field.keyup(function(e) {
|
||||||
|
if (e.originalEvent.charCode === 13)
|
||||||
|
return;
|
||||||
|
|
||||||
|
let pattern = field.val();
|
||||||
|
// If the pattern is not modified, we don't query the API
|
||||||
|
if (pattern === old_pattern || pattern === "")
|
||||||
|
return;
|
||||||
|
|
||||||
|
old_pattern = pattern;
|
||||||
|
|
||||||
|
// Clear old matched notes
|
||||||
|
notes.length = 0;
|
||||||
|
|
||||||
|
let aliases_matched_obj = $("#" + alias_matched_id);
|
||||||
|
let aliases_matched_html = "";
|
||||||
|
|
||||||
|
// Get matched notes with the given pattern
|
||||||
|
getMatchedNotes(pattern, function(aliases) {
|
||||||
|
// The response arrived too late, we stop the request
|
||||||
|
if (pattern !== $("#" + field_id).val())
|
||||||
|
return;
|
||||||
|
|
||||||
|
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);
|
||||||
|
notes.push(note);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Display the list of matched aliases
|
||||||
|
aliases_matched_obj.html(aliases_matched_html);
|
||||||
|
|
||||||
|
notes.forEach(function (note) {
|
||||||
|
let alias = note.alias;
|
||||||
|
let alias_obj = $("#" + alias_prefix + "_" + alias.id);
|
||||||
|
// When an alias is hovered, the profile picture and the balance are displayed at the right place
|
||||||
|
alias_obj.hover(function () {
|
||||||
|
displayNote(note, alias.name, user_note_field, profile_pic_field);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
// We compare the note ids
|
||||||
|
if (d.id === note.id) {
|
||||||
|
d.quantity += 1;
|
||||||
|
disp = d;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// In the other case, we add a new emitter
|
||||||
|
if (disp == null) {
|
||||||
|
disp = {
|
||||||
|
name: alias.name,
|
||||||
|
id: note.id,
|
||||||
|
note: note,
|
||||||
|
quantity: 1
|
||||||
|
};
|
||||||
|
notes_display.push(disp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the function alias_click exists, it is called. If it doesn't return true, then the notes are
|
||||||
|
// note displayed. Useful for a consumption when a button is already clicked
|
||||||
|
if (alias_click && !alias_click())
|
||||||
|
return;
|
||||||
|
|
||||||
|
let note_list = $("#" + note_list_id);
|
||||||
|
let html = "";
|
||||||
|
notes_display.forEach(function (disp) {
|
||||||
|
html += li(note_prefix + "_" + disp.id, disp.name
|
||||||
|
+ "<span class=\"badge badge-dark badge-pill\">" + disp.quantity + "</span>");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emitters are displayed
|
||||||
|
note_list.html(html);
|
||||||
|
|
||||||
|
notes_display.forEach(function (disp) {
|
||||||
|
let line_obj = $("#" + note_prefix + "_" + disp.id);
|
||||||
|
// Hover an emitter display also the profile picture
|
||||||
|
line_obj.hover(function () {
|
||||||
|
displayNote(disp.note, disp.name, user_note_field, profile_pic_field);
|
||||||
|
});
|
||||||
|
|
||||||
|
// When an emitter is clicked, it is removed
|
||||||
|
line_obj.click(removeNote(disp, note_prefix, notes_display, note_list_id, user_note_field,
|
||||||
|
profile_pic_field));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// When a validate button is clicked, we switch the validation status
|
||||||
|
function de_validate(id, validated) {
|
||||||
|
$("#validate_" + id).html("<strong style=\"font-size: 16pt;\">⟳ ...</strong>");
|
||||||
|
|
||||||
|
// Perform a PATCH request to the API in order to update the transaction
|
||||||
|
// If the user has insuffisent rights, an error message will appear
|
||||||
|
$.ajax({
|
||||||
|
"url": "/api/note/transaction/transaction/" + id + "/",
|
||||||
|
type: "PATCH",
|
||||||
|
dataType: "json",
|
||||||
|
headers: {
|
||||||
|
"X-CSRFTOKEN": CSRF_TOKEN
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
"resourcetype": "RecurrentTransaction",
|
||||||
|
valid: !validated
|
||||||
|
},
|
||||||
|
success: function () {
|
||||||
|
// Refresh jQuery objects
|
||||||
|
$(".validate").click(de_validate);
|
||||||
|
|
||||||
|
refreshBalance();
|
||||||
|
// error if this method doesn't exist. Please define it.
|
||||||
|
refreshHistory();
|
||||||
|
},
|
||||||
|
error: function(err) {
|
||||||
|
addMsg("Une erreur est survenue lors de la validation/dévalidation " +
|
||||||
|
"de cette transaction : " + err.responseText, "danger");
|
||||||
|
|
||||||
|
refreshBalance();
|
||||||
|
// error if this method doesn't exist. Please define it.
|
||||||
|
refreshHistory();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
206
static/js/consos.js
Normal file
206
static/js/consos.js
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
// Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the history table on the consumptions page.
|
||||||
|
*/
|
||||||
|
function refreshHistory() {
|
||||||
|
$("#history").load("/note/consos/ #history");
|
||||||
|
$("#most_used").load("/note/consos/ #most_used");
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
// If hash of a category in the URL, then select this category
|
||||||
|
// else select the first one
|
||||||
|
if (location.hash) {
|
||||||
|
$("a[href='" + location.hash + "']").tab("show");
|
||||||
|
} else {
|
||||||
|
$("a[data-toggle='tab']").first().tab("show");
|
||||||
|
}
|
||||||
|
|
||||||
|
// When selecting a category, change URL
|
||||||
|
$(document.body).on("click", "a[data-toggle='tab']", function() {
|
||||||
|
location.hash = this.getAttribute("href");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switching in double consumptions mode should update the layout
|
||||||
|
let double_conso_obj = $("#double_conso");
|
||||||
|
double_conso_obj.click(function() {
|
||||||
|
$("#consos_list_div").show();
|
||||||
|
$("#infos_div").attr('class', 'col-sm-5 col-xl-6');
|
||||||
|
$("#note_infos_div").attr('class', 'col-xl-3');
|
||||||
|
$("#user_select_div").attr('class', 'col-xl-4');
|
||||||
|
$("#buttons_div").attr('class', 'col-sm-7 col-xl-6');
|
||||||
|
|
||||||
|
let note_list_obj = $("#note_list");
|
||||||
|
if (buttons.length > 0 && note_list_obj.text().length > 0) {
|
||||||
|
$("#consos_list").html(note_list_obj.html());
|
||||||
|
note_list_obj.html("");
|
||||||
|
|
||||||
|
buttons.forEach(function(button) {
|
||||||
|
$("#conso_button_" + button.id).click(removeNote(button, "conso_button", buttons,
|
||||||
|
"consos_list"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let single_conso_obj = $("#single_conso");
|
||||||
|
single_conso_obj.click(function() {
|
||||||
|
$("#consos_list_div").hide();
|
||||||
|
$("#infos_div").attr('class', 'col-sm-5 col-md-4');
|
||||||
|
$("#note_infos_div").attr('class', 'col-xl-5');
|
||||||
|
$("#user_select_div").attr('class', 'col-xl-7');
|
||||||
|
$("#buttons_div").attr('class', 'col-sm-7 col-md-8');
|
||||||
|
|
||||||
|
let consos_list_obj = $("#consos_list");
|
||||||
|
if (buttons.length > 0) {
|
||||||
|
if (notes_display.length === 0 && consos_list_obj.text().length > 0) {
|
||||||
|
$("#note_list").html(consos_list_obj.html());
|
||||||
|
consos_list_obj.html("");
|
||||||
|
buttons.forEach(function(button) {
|
||||||
|
$("#conso_button_" + button.id).click(removeNote(button, "conso_button", buttons,
|
||||||
|
"note_list"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
buttons.length = 0;
|
||||||
|
consos_list_obj.html("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure we begin in single consumption. Removing these lines may cause problems when reloading.
|
||||||
|
single_conso_obj.prop('checked', 'true');
|
||||||
|
double_conso_obj.removeAttr('checked');
|
||||||
|
$("label[for='double_conso']").attr('class', 'btn btn-sm btn-outline-primary');
|
||||||
|
|
||||||
|
$("#consos_list_div").hide();
|
||||||
|
|
||||||
|
$("#consume_all").click(consumeAll);
|
||||||
|
});
|
||||||
|
|
||||||
|
notes = [];
|
||||||
|
notes_display = [];
|
||||||
|
buttons = [];
|
||||||
|
|
||||||
|
// When the user searches an alias, we update the auto-completion
|
||||||
|
autoCompleteNote("note", "alias_matched", "note_list", notes, notes_display,
|
||||||
|
"alias", "note", "user_note", "profile_pic", function() {
|
||||||
|
if (buttons.length > 0 && $("#single_conso").is(":checked")) {
|
||||||
|
consumeAll();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 RecurrentTransaction)
|
||||||
|
* @param category_id The category identifier
|
||||||
|
* @param category_name The category name
|
||||||
|
* @param template_id The identifier of the button
|
||||||
|
* @param template_name The name of the button
|
||||||
|
*/
|
||||||
|
function addConso(dest, amount, type, category_id, category_name, template_id, template_name) {
|
||||||
|
var button = null;
|
||||||
|
buttons.forEach(function(b) {
|
||||||
|
if (b.id === template_id) {
|
||||||
|
b.quantity += 1;
|
||||||
|
button = b;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (button == null) {
|
||||||
|
button = {
|
||||||
|
id: template_id,
|
||||||
|
name: template_name,
|
||||||
|
dest: dest,
|
||||||
|
quantity: 1,
|
||||||
|
amount: amount,
|
||||||
|
type: type,
|
||||||
|
category_id: category_id,
|
||||||
|
category_name: category_name
|
||||||
|
};
|
||||||
|
buttons.push(button);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dc_obj = $("#double_conso");
|
||||||
|
if (dc_obj.is(":checked") || notes_display.length === 0) {
|
||||||
|
let list = dc_obj.is(":checked") ? "consos_list" : "note_list";
|
||||||
|
let html = "";
|
||||||
|
buttons.forEach(function(button) {
|
||||||
|
html += li("conso_button_" + button.id, button.name
|
||||||
|
+ "<span class=\"badge badge-dark badge-pill\">" + button.quantity + "</span>");
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#" + list).html(html);
|
||||||
|
|
||||||
|
buttons.forEach(function(button) {
|
||||||
|
$("#conso_button_" + button.id).click(removeNote(button, "conso_button", buttons, list));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
consumeAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the page as its initial state.
|
||||||
|
*/
|
||||||
|
function reset() {
|
||||||
|
notes_display.length = 0;
|
||||||
|
notes.length = 0;
|
||||||
|
buttons.length = 0;
|
||||||
|
$("#note_list").html("");
|
||||||
|
$("#alias_matched").html("");
|
||||||
|
$("#consos_list").html("");
|
||||||
|
$("#user_note").text("");
|
||||||
|
$("#profile_pic").attr("src", "/media/pic/default.png");
|
||||||
|
refreshHistory();
|
||||||
|
refreshBalance();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply all transactions: all notes in `notes` buy each item in `buttons`
|
||||||
|
*/
|
||||||
|
function consumeAll() {
|
||||||
|
notes_display.forEach(function(note_display) {
|
||||||
|
buttons.forEach(function(button) {
|
||||||
|
consume(note_display.id, button.dest, button.quantity * note_display.quantity, button.amount,
|
||||||
|
button.name + " (" + button.category_name + ")", button.type, button.category_id, button.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new transaction from a button through the API.
|
||||||
|
* @param source The note that paid the item (type: int)
|
||||||
|
* @param dest The note that sold the item (type: int)
|
||||||
|
* @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 RecurrentTransaction)
|
||||||
|
* @param category The category id of the button (type: int)
|
||||||
|
* @param template The button id (type: int)
|
||||||
|
*/
|
||||||
|
function consume(source, dest, quantity, amount, reason, type, category, template) {
|
||||||
|
$.post("/api/note/transaction/transaction/",
|
||||||
|
{
|
||||||
|
"csrfmiddlewaretoken": CSRF_TOKEN,
|
||||||
|
"quantity": quantity,
|
||||||
|
"amount": amount,
|
||||||
|
"reason": reason,
|
||||||
|
"valid": true,
|
||||||
|
"polymorphic_ctype": type,
|
||||||
|
"resourcetype": "RecurrentTransaction",
|
||||||
|
"source": source,
|
||||||
|
"destination": dest,
|
||||||
|
"category": category,
|
||||||
|
"template": template
|
||||||
|
}, reset).fail(function (e) {
|
||||||
|
reset();
|
||||||
|
|
||||||
|
addMsg("Une erreur est survenue lors de la transaction : " + e.responseText, "danger");
|
||||||
|
});
|
||||||
|
}
|
161
static/js/transfer.js
Normal file
161
static/js/transfer.js
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
sources = [];
|
||||||
|
sources_notes_display = [];
|
||||||
|
dests = [];
|
||||||
|
dests_notes_display = [];
|
||||||
|
|
||||||
|
function refreshHistory() {
|
||||||
|
$("#history").load("/note/transfer/ #history");
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
sources_notes_display.length = 0;
|
||||||
|
sources.length = 0;
|
||||||
|
dests_notes_display.length = 0;
|
||||||
|
dests.length = 0;
|
||||||
|
$("#source_note_list").html("");
|
||||||
|
$("#dest_note_list").html("");
|
||||||
|
$("#source_alias_matched").html("");
|
||||||
|
$("#dest_alias_matched").html("");
|
||||||
|
$("#amount").val("");
|
||||||
|
$("#reason").val("");
|
||||||
|
$("#last_name").val("");
|
||||||
|
$("#first_name").val("");
|
||||||
|
$("#bank").val("");
|
||||||
|
$("#user_note").val("");
|
||||||
|
$("#profile_pic").attr("src", "/media/pic/default.png");
|
||||||
|
refreshBalance();
|
||||||
|
refreshHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
autoCompleteNote("source_note", "source_alias_matched", "source_note_list", sources, sources_notes_display,
|
||||||
|
"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() {
|
||||||
|
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;
|
||||||
|
|
||||||
|
$.getJSON("/api/user/" + last.note.user + "/", function(user) {
|
||||||
|
$("#last_name").val(user.last_name);
|
||||||
|
$("#first_name").val(user.first_name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Ensure we begin in gift mode. Removing these lines may cause problems when reloading.
|
||||||
|
$("#type_gift").prop('checked', 'true');
|
||||||
|
$("#type_transfer").removeAttr('checked');
|
||||||
|
$("#type_credit").removeAttr('checked');
|
||||||
|
$("#type_debit").removeAttr('checked');
|
||||||
|
$("label[for='type_transfer']").attr('class', 'btn btn-sm btn-outline-primary');
|
||||||
|
$("label[for='type_credit']").attr('class', 'btn btn-sm btn-outline-primary');
|
||||||
|
$("label[for='type_debit']").attr('class', 'btn btn-sm btn-outline-primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#transfer").click(function() {
|
||||||
|
if ($("#type_gift").is(':checked')) {
|
||||||
|
dests_notes_display.forEach(function (dest) {
|
||||||
|
$.post("/api/note/transaction/transaction/",
|
||||||
|
{
|
||||||
|
"csrfmiddlewaretoken": CSRF_TOKEN,
|
||||||
|
"quantity": dest.quantity,
|
||||||
|
"amount": 100 * $("#amount").val(),
|
||||||
|
"reason": $("#reason").val(),
|
||||||
|
"valid": true,
|
||||||
|
"polymorphic_ctype": TRANSFER_POLYMORPHIC_CTYPE,
|
||||||
|
"resourcetype": "Transaction",
|
||||||
|
"source": user_id,
|
||||||
|
"destination": dest.id
|
||||||
|
}, function () {
|
||||||
|
addMsg("Le transfert de "
|
||||||
|
+ pretty_money(dest.quantity * 100 * $("#amount").val()) + " de votre note "
|
||||||
|
+ " vers la note " + dest.name + " a été fait avec succès !", "success");
|
||||||
|
|
||||||
|
reset();
|
||||||
|
}).fail(function (err) {
|
||||||
|
addMsg("Le transfert de "
|
||||||
|
+ pretty_money(dest.quantity * 100 * $("#amount").val()) + " de votre note "
|
||||||
|
+ " vers la note " + dest.name + " a échoué : " + err.responseText, "danger");
|
||||||
|
|
||||||
|
reset();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if ($("#type_transfer").is(':checked')) {
|
||||||
|
sources_notes_display.forEach(function (source) {
|
||||||
|
dests_notes_display.forEach(function (dest) {
|
||||||
|
$.post("/api/note/transaction/transaction/",
|
||||||
|
{
|
||||||
|
"csrfmiddlewaretoken": CSRF_TOKEN,
|
||||||
|
"quantity": source.quantity * dest.quantity,
|
||||||
|
"amount": 100 * $("#amount").val(),
|
||||||
|
"reason": $("#reason").val(),
|
||||||
|
"valid": true,
|
||||||
|
"polymorphic_ctype": TRANSFER_POLYMORPHIC_CTYPE,
|
||||||
|
"resourcetype": "Transaction",
|
||||||
|
"source": source.id,
|
||||||
|
"destination": dest.id
|
||||||
|
}, function () {
|
||||||
|
addMsg("Le transfert de "
|
||||||
|
+ pretty_money(source.quantity * dest.quantity * 100 * $("#amount").val()) + " de la note " + source.name
|
||||||
|
+ " vers la note " + dest.name + " a été fait avec succès !", "success");
|
||||||
|
|
||||||
|
reset();
|
||||||
|
}).fail(function (err) {
|
||||||
|
addMsg("Le transfert de "
|
||||||
|
+ pretty_money(source.quantity * dest.quantity * 100 * $("#amount").val()) + " de la note " + source.name
|
||||||
|
+ " vers la note " + dest.name + " a échoué : " + err.responseText, "danger");
|
||||||
|
|
||||||
|
reset();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else if ($("#type_credit").is(':checked') || $("#type_debit").is(':checked')) {
|
||||||
|
let special_note = $("#credit_type").val();
|
||||||
|
let user_note = dests_notes_display[0].id;
|
||||||
|
let given_reason = $("#reason").val();
|
||||||
|
let source, dest, reason;
|
||||||
|
if ($("#type_credit").is(':checked')) {
|
||||||
|
source = special_note;
|
||||||
|
dest = user_note;
|
||||||
|
reason = "Crédit " + $("#credit_type option:selected").text().toLowerCase();
|
||||||
|
if (given_reason.length > 0)
|
||||||
|
reason += " (" + given_reason + ")";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
source = user_note;
|
||||||
|
dest = special_note;
|
||||||
|
reason = "Retrait " + $("#credit_type option:selected").text().toLowerCase();
|
||||||
|
if (given_reason.length > 0)
|
||||||
|
reason += " (" + given_reason + ")";
|
||||||
|
}
|
||||||
|
$.post("/api/note/transaction/transaction/",
|
||||||
|
{
|
||||||
|
"csrfmiddlewaretoken": CSRF_TOKEN,
|
||||||
|
"quantity": 1,
|
||||||
|
"amount": 100 * $("#amount").val(),
|
||||||
|
"reason": reason,
|
||||||
|
"valid": true,
|
||||||
|
"polymorphic_ctype": SPECIAL_TRANSFER_POLYMORPHIC_CTYPE,
|
||||||
|
"resourcetype": "SpecialTransaction",
|
||||||
|
"source": source,
|
||||||
|
"destination": dest,
|
||||||
|
"last_name": $("#last_name").val(),
|
||||||
|
"first_name": $("#first_name").val(),
|
||||||
|
"bank": $("#bank").val()
|
||||||
|
}, function () {
|
||||||
|
addMsg("Le crédit/retrait a bien été effectué !", "success");
|
||||||
|
reset();
|
||||||
|
}).fail(function (err) {
|
||||||
|
addMsg("Le crédit/transfert a échoué : " + err.responseText, "danger");
|
||||||
|
reset();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
@ -1,4 +1,4 @@
|
|||||||
{% load static i18n pretty_money static getenv %}
|
{% load static i18n pretty_money static getenv perms %}
|
||||||
{% comment %}
|
{% comment %}
|
||||||
SPDX-License-Identifier: GPL-3.0-or-later
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
@ -46,12 +46,20 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/turbolinks/5.2.0/turbolinks.js"
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/turbolinks/5.2.0/turbolinks.js"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
|
<script src="/static/js/base.js"></script>
|
||||||
|
|
||||||
{# Si un formulaire requiert des données supplémentaires (notamment JS), les données sont chargées #}
|
{# Si un formulaire requiert des données supplémentaires (notamment JS), les données sont chargées #}
|
||||||
{% if form.media %}
|
{% if form.media %}
|
||||||
{{ form.media }}
|
{{ form.media }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.validate:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
{% block extracss %}{% endblock %}
|
{% block extracss %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="d-flex w-100 h-100 flex-column">
|
<body class="d-flex w-100 h-100 flex-column">
|
||||||
@ -66,30 +74,41 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
</button>
|
</button>
|
||||||
<div class="collapse navbar-collapse" id="navbarNavDropdown">
|
<div class="collapse navbar-collapse" id="navbarNavDropdown">
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
<li class="nav-item active">
|
{% if "note.transactiontemplate"|not_empty_model_list %}
|
||||||
<a class="nav-link" href="{% url 'note:consos' %}"><i class="fa fa-coffee"></i> {% trans 'Consumptions' %}</a>
|
<li class="nav-item active">
|
||||||
</li>
|
<a class="nav-link" href="{% url 'note:consos' %}"><i class="fa fa-coffee"></i> {% trans 'Consumptions' %}</a>
|
||||||
<li class="nav-item active">
|
</li>
|
||||||
<a class="nav-link" href="{% url 'member:club_list' %}"><i class="fa fa-users"></i> {% trans 'Clubs' %}</a>
|
{% endif %}
|
||||||
</li>
|
{% if "member.club"|not_empty_model_list %}
|
||||||
<li class="nav-item active">
|
<li class="nav-item active">
|
||||||
<a class="nav-link" href="#"><i class="fa fa-calendar"></i> {% trans 'Activities' %}</a>
|
<a class="nav-link" href="{% url 'member:club_list' %}"><i class="fa fa-users"></i> {% trans 'Clubs' %}</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item active">
|
{% endif %}
|
||||||
<a class="nav-link" href="{% url 'note:template_list' %}"><i class="fa fa-coffee"></i> {% trans 'Buttons' %}</a>
|
{% if "activity.activity"|not_empty_model_list %}
|
||||||
</li>
|
<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">
|
<li class="nav-item active">
|
||||||
<a class="nav-link" href="{% url 'note:transfer' %}"><i class="fa fa-exchange"></i>{% trans 'Transfer' %} </a>
|
<a class="nav-link" href="{% url 'note:transfer' %}"><i class="fa fa-exchange"></i>{% trans 'Transfer' %} </a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item active">
|
{% if "treasury.billing"|not_empty_model_change_list %}
|
||||||
<a class="nav-link" href="{% url 'treasury:billing' %}"><i class="fa fa-money"></i>{% trans 'Treasury' %} </a>
|
<li class="nav-item active">
|
||||||
</li>
|
<a class="nav-link" href="{% url 'treasury:billing' %}"><i class="fa fa-money"></i>{% trans 'Treasury' %} </a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="navbar-nav ml-auto">
|
<ul class="navbar-nav ml-auto">
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<li class="dropdown">
|
<li class="dropdown">
|
||||||
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
<i class="fa fa-user"></i> {{ user.username }} ({{ user.note.balance | pretty_money }})
|
<i class="fa fa-user"></i>
|
||||||
|
<span id="user_balance">{{ user.username }} ({{ user.note.balance | pretty_money }})</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="dropdown-menu dropdown-menu-right"
|
<div class="dropdown-menu dropdown-menu-right"
|
||||||
aria-labelledby="navbarDropdownMenuLink">
|
aria-labelledby="navbarDropdownMenuLink">
|
||||||
@ -118,6 +137,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
</nav>
|
</nav>
|
||||||
<div class="container-fluid my-3" style="max-width: 1600px;">
|
<div class="container-fluid my-3" style="max-width: 1600px;">
|
||||||
{% block contenttitle %}<h1>{{ title }}</h1>{% endblock %}
|
{% block contenttitle %}<h1>{{ title }}</h1>{% endblock %}
|
||||||
|
<div id="messages"></div>
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p>Default content...</p>
|
<p>Default content...</p>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
@ -161,6 +181,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
CSRF_TOKEN = "{{ csrf_token }}";
|
||||||
|
</script>
|
||||||
|
|
||||||
{% block extrajavascript %}
|
{% block extrajavascript %}
|
||||||
{% endblock extrajavascript %}
|
{% endblock extrajavascript %}
|
||||||
</body>
|
</body>
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
<img src="{{ object.note.display_image.url }}" class="img-thumbnail mt-2" >
|
<img src="{{ object.note.display_image.url }}" class="img-thumbnail mt-2" >
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body" id="profile_infos">
|
||||||
<dl class="row">
|
<dl class="row">
|
||||||
<dt class="col-xl-6">{% trans 'name'|capfirst %}, {% trans 'first name' %}</dt>
|
<dt class="col-xl-6">{% trans 'name'|capfirst %}, {% trans 'first name' %}</dt>
|
||||||
<dd class="col-xl-6">{{ object.last_name }} {{ object.first_name }}</dd>
|
<dd class="col-xl-6">{{ object.last_name }} {{ object.first_name }}</dd>
|
||||||
@ -76,7 +76,9 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div id="historyListCollapse" class="collapse" style="overflow:auto hidden" aria-labelledby="historyListHeading" data-parent="#accordionProfile">
|
<div id="historyListCollapse" class="collapse" style="overflow:auto hidden" aria-labelledby="historyListHeading" data-parent="#accordionProfile">
|
||||||
{% render_table history_list %}
|
<div id="history_list">
|
||||||
|
{% render_table history_list %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -84,3 +86,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extrajavascript %}
|
||||||
|
<script>
|
||||||
|
function refreshHistory() {
|
||||||
|
$("#history_list").load("{% url 'member:user_detail' pk=object.pk %} #history_list");
|
||||||
|
$("#profile_infos").load("{% url 'member:user_detail' pk=object.pk %} #profile_infos");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
@ -7,67 +7,88 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
<div class="col-sm-5 col-md-4">
|
<div class="col-sm-5 col-md-4" id="infos_div">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{# User details column #}
|
{# User details column #}
|
||||||
<div class="col-xl-5">
|
<div class="col-xl-5" id="note_infos_div">
|
||||||
<div class="card border-success shadow mb-4">
|
<div class="card border-success shadow mb-4">
|
||||||
<img src="https://perso.crans.org/erdnaxe/site-crans/img/logo.svg"
|
<img src="/media/pic/default.png"
|
||||||
alt="" class="img-fluid rounded mx-auto d-block">
|
id="profile_pic" alt="" class="img-fluid rounded mx-auto d-block">
|
||||||
<div class="card-body text-center">
|
<div class="card-body text-center">
|
||||||
Paquito (aka. PAC) : -230 €
|
<span id="user_note"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# User selection column #}
|
{# User selection column #}
|
||||||
<div class="col-xl-7">
|
<div class="col-xl-7" id="user_select_div">
|
||||||
<div class="card border-success shadow mb-4">
|
<div class="card border-success shadow mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<p class="card-text font-weight-bold">
|
<p class="card-text font-weight-bold">
|
||||||
Sélection des émitteurs
|
{% trans "Select emitters" %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ul class="list-group list-group-flush">
|
<ul class="list-group list-group-flush" id="note_list">
|
||||||
<li class="list-group-item py-1 d-flex justify-content-between align-items-center">
|
|
||||||
Cras justo odio
|
|
||||||
<span class="badge badge-dark badge-pill">14</span>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item py-1 d-flex justify-content-between align-items-center">
|
|
||||||
Dapibus ac facilisis in
|
|
||||||
<span class="badge badge-dark badge-pill">1</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
TODO: reimplement select2 here in JS
|
<input class="form-control mx-auto d-block" type="text" id="note" />
|
||||||
|
<ul class="list-group list-group-flush" id="alias_matched">
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-xl-5" id="consos_list_div">
|
||||||
|
<div class="card border-info shadow mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<p class="card-text font-weight-bold">
|
||||||
|
{% trans "Select consumptions" %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ul class="list-group list-group-flush" id="consos_list">
|
||||||
|
</ul>
|
||||||
|
<button id="consume_all" class="form-control btn btn-primary">
|
||||||
|
{% trans "Consume!" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Buttons column #}
|
{# Buttons column #}
|
||||||
<div class="col-sm-7 col-md-8">
|
<div class="col-sm-7 col-md-8" id="buttons_div">
|
||||||
{# Show last used buttons #}
|
{# Show last used buttons #}
|
||||||
<div class="card shadow mb-4">
|
<div class="card shadow mb-4">
|
||||||
<div class="card-body text-nowrap" style="overflow:auto hidden">
|
<div class="card-header">
|
||||||
<p class="card-text text-muted font-weight-light font-italic">
|
<p class="card-text font-weight-bold">
|
||||||
Les boutons les plus utilisés s'afficheront ici.
|
{% trans "Most used buttons" %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-body text-nowrap" style="overflow:auto hidden">
|
||||||
|
<div class="d-inline-flex flex-wrap justify-content-center" id="most_used">
|
||||||
|
{% for button in most_used %}
|
||||||
|
{% if button.display %}
|
||||||
|
<button class="btn btn-outline-dark rounded-0 flex-fill"
|
||||||
|
id="most_used_button{{ button.id }}" name="button" value="{{ button.name }}">
|
||||||
|
{{ button.name }} ({{ button.amount | pretty_money }})
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Regroup buttons under categories #}
|
{# Regroup buttons under categories #}
|
||||||
{% regroup transaction_templates by template_type as template_types %}
|
{% regroup transaction_templates by category as categories %}
|
||||||
|
|
||||||
<div class="card border-primary text-center shadow mb-4">
|
<div class="card border-primary text-center shadow mb-4">
|
||||||
{# Tabs for button categories #}
|
{# Tabs for button categories #}
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<ul class="nav nav-tabs nav-fill card-header-tabs">
|
<ul class="nav nav-tabs nav-fill card-header-tabs">
|
||||||
{% for template_type in template_types %}
|
{% for category in categories %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link font-weight-bold" data-toggle="tab" href="#{{ template_type.grouper|slugify }}">
|
<a class="nav-link font-weight-bold" data-toggle="tab" href="#{{ category.grouper|slugify }}">
|
||||||
{{ template_type.grouper }}
|
{{ category.grouper }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -77,14 +98,16 @@
|
|||||||
{# Tabs content #}
|
{# Tabs content #}
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
{% for template_type in template_types %}
|
{% for category in categories %}
|
||||||
<div class="tab-pane" id="{{ template_type.grouper|slugify }}">
|
<div class="tab-pane" id="{{ category.grouper|slugify }}">
|
||||||
<div class="d-inline-flex flex-wrap justify-content-center">
|
<div class="d-inline-flex flex-wrap justify-content-center">
|
||||||
{% for button in template_type.list %}
|
{% for button in category.list %}
|
||||||
<button class="btn btn-outline-dark rounded-0 flex-fill"
|
{% if button.display %}
|
||||||
name="button" value="{{ button.name }}">
|
<button class="btn btn-outline-dark rounded-0 flex-fill"
|
||||||
{{ button.name }} ({{ button.amount | pretty_money }})
|
id="button{{ button.id }}" name="button" value="{{ button.name }}">
|
||||||
</button>
|
{{ button.name }} ({{ button.amount | pretty_money }})
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -95,16 +118,16 @@
|
|||||||
{# Mode switch #}
|
{# Mode switch #}
|
||||||
<div class="card-footer border-primary">
|
<div class="card-footer border-primary">
|
||||||
<a class="btn btn-sm btn-secondary float-left" href="{% url 'note:template_list' %}">
|
<a class="btn btn-sm btn-secondary float-left" href="{% url 'note:template_list' %}">
|
||||||
<i class="fa fa-edit"></i> Éditer
|
<i class="fa fa-edit"></i> {% trans "Edit" %}
|
||||||
</a>
|
</a>
|
||||||
<div class="btn-group btn-group-toggle float-right" data-toggle="buttons">
|
<div class="btn-group btn-group-toggle float-right" data-toggle="buttons">
|
||||||
<label class="btn btn-sm btn-outline-primary active">
|
<label for="single_conso" class="btn btn-sm btn-outline-primary active">
|
||||||
<input type="radio" name="options" id="option1" checked>
|
<input type="radio" name="conso_type" id="single_conso" checked>
|
||||||
Consomations simples
|
{% trans "Single consumptions" %}
|
||||||
</label>
|
</label>
|
||||||
<label class="btn btn-sm btn-outline-primary">
|
<label for="double_conso" class="btn btn-sm btn-outline-primary">
|
||||||
<input type="radio" name="options" id="option2">
|
<input type="radio" name="conso_type" id="double_conso">
|
||||||
Consomations doubles
|
{% trans "Double consumptions" %}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -112,40 +135,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card shadow mb-4">
|
<div class="card shadow mb-4" id="history">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<p class="card-text font-weight-bold">
|
<p class="card-text font-weight-bold">
|
||||||
Historique des transactions récentes
|
{% trans "Recent transactions history" %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% render_table table %}
|
{% render_table table %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extracss %}
|
|
||||||
<style>
|
|
||||||
.select2-container{
|
|
||||||
max-width: 100%;
|
|
||||||
min-width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extrajavascript %}
|
{% block extrajavascript %}
|
||||||
|
<script type="text/javascript" src="/static/js/consos.js"></script>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
$(document).ready(function() {
|
{% for button in most_used %}
|
||||||
// If hash of a category in the URL, then select this category
|
{% if button.display %}
|
||||||
// else select the first one
|
$("#most_used_button{{ button.id }}").click(function() {
|
||||||
if (location.hash) {
|
addConso({{ button.destination.id }}, {{ button.amount }},
|
||||||
$("a[href='" + location.hash + "']").tab("show");
|
{{ polymorphic_ctype }}, {{ button.category.id }}, "{{ button.category.name }}",
|
||||||
} else {
|
{{ button.id }}, "{{ button.name }}");
|
||||||
$("a[data-toggle='tab']").first().tab("show");
|
});
|
||||||
}
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
// When selecting a category, change URL
|
{% for button in transaction_templates %}
|
||||||
$(document.body).on("click", "a[data-toggle='tab']", function(event) {
|
{% if button.display %}
|
||||||
location.hash = this.getAttribute("href");
|
$("#button{{ button.id }}").click(function() {
|
||||||
});
|
addConso({{ button.destination.id }}, {{ button.amount }},
|
||||||
});
|
{{ polymorphic_ctype }}, {{ button.category.id }}, "{{ button.category.name }}",
|
||||||
|
{{ button.id }}, "{{ button.name }}");
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -3,35 +3,192 @@
|
|||||||
SPDX-License-Identifier: GPL-2.0-or-later
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
|
|
||||||
{% load i18n static %}
|
{% load i18n static django_tables2 perms %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<form method="post" onsubmit="window.onbeforeunload=null">{% csrf_token %}
|
|
||||||
{% if form.non_field_errors %}
|
<div class="row">
|
||||||
<p class="errornote">
|
<div class="col-xl-12">
|
||||||
{% for error in form.non_field_errors %}
|
<div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0" data-toggle="buttons">
|
||||||
{{ error }}
|
<label for="type_gift" class="btn btn-sm btn-outline-primary active">
|
||||||
{% endfor %}
|
<input type="radio" name="transaction_type" id="type_gift" checked>
|
||||||
</p>
|
{% trans "Gift" %}
|
||||||
{% endif %}
|
</label>
|
||||||
<fieldset class="module aligned">
|
<label for="type_transfer" class="btn btn-sm btn-outline-primary">
|
||||||
{% for field in form %}
|
<input type="radio" name="transaction_type" id="type_transfer">
|
||||||
<div class="form-row{% if field.errors %} errors{% endif %}">
|
{% trans "Transfer" %}
|
||||||
{{ field.errors }}
|
</label>
|
||||||
<div>
|
{% if "note.notespecial"|not_empty_model_list %}
|
||||||
{{ field.label_tag }}
|
<label for="type_credit" class="btn btn-sm btn-outline-primary">
|
||||||
{% if field.is_readonly %}
|
<input type="radio" name="transaction_type" id="type_credit">
|
||||||
<div class="readonly">{{ field.contents }}</div>
|
{% trans "Credit" %}
|
||||||
{% else %}
|
</label>
|
||||||
{{ field }}
|
<label type="type_debit" class="btn btn-sm btn-outline-primary">
|
||||||
{% endif %}
|
<input type="radio" name="transaction_type" id="type_debit">
|
||||||
{% if field.field.help_text %}
|
{% trans "Debit" %}
|
||||||
<div class="help">{{ field.field.help_text|safe }}</div>
|
</label>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4" id="emitters_div" style="display: none;">
|
||||||
|
<div class="card border-success shadow mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<p class="card-text font-weight-bold">
|
||||||
|
{% trans "Select emitters" %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ul class="list-group list-group-flush" id="source_note_list">
|
||||||
|
</ul>
|
||||||
|
<div class="card-body">
|
||||||
|
<input class="form-control mx-auto d-block" type="text" id="source_note" />
|
||||||
|
<ul class="list-group list-group-flush" id="source_alias_matched">
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-xl-4" id="note_infos_div">
|
||||||
|
<div class="card border-success shadow mb-4">
|
||||||
|
<img src="/media/pic/default.png"
|
||||||
|
id="profile_pic" alt="" class="img-fluid rounded mx-auto d-block">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<span id="user_note"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
<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 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 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>
|
||||||
{% endfor %}
|
</div>
|
||||||
</fieldset>
|
{% endif %}
|
||||||
<input type="submit" value="{% trans 'Transfer' %}">
|
|
||||||
</form>
|
<div class="col-md-8" id="dests_div">
|
||||||
|
<div class="card border-info shadow mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<p class="card-text font-weight-bold" id="dest_title">
|
||||||
|
{% trans "Select receivers" %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ul class="list-group list-group-flush" id="dest_note_list">
|
||||||
|
</ul>
|
||||||
|
<div class="card-body">
|
||||||
|
<input class="form-control mx-auto d-block" type="text" id="dest_note" />
|
||||||
|
<ul class="list-group list-group-flush" id="dest_alias_matched">
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group col-md-6">
|
||||||
|
<label for="amount">{% trans "Amount" %} :</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input class="form-control mx-auto d-block" type="number" min="0" step="0.01" id="amount" />
|
||||||
|
<div class="input-group-append">
|
||||||
|
<span class="input-group-text">€</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group col-md-6">
|
||||||
|
<label for="reason">{% trans "Reason" %} :</label>
|
||||||
|
<input class="form-control mx-auto d-block" type="text" id="reason" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<button id="transfer" class="form-control btn btn-primary">{% trans 'Transfer' %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow mb-4" id="history">
|
||||||
|
<div class="card-header">
|
||||||
|
<p class="card-text font-weight-bold">
|
||||||
|
{% trans "Recent transactions history" %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% render_table table %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extrajavascript %}
|
||||||
|
<script>
|
||||||
|
TRANSFER_POLYMORPHIC_CTYPE = {{ polymorphic_ctype }};
|
||||||
|
SPECIAL_TRANSFER_POLYMORPHIC_CTYPE = {{ special_polymorphic_ctype }};
|
||||||
|
user_id = {{ user.note.pk }};
|
||||||
|
|
||||||
|
$("#type_gift").click(function() {
|
||||||
|
$("#emitters_div").hide();
|
||||||
|
$("#external_div").hide();
|
||||||
|
$("#dests_div").attr('class', 'col-md-8');
|
||||||
|
$("#dest_title").text("{% trans "Select receivers" %}");
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#type_transfer").click(function() {
|
||||||
|
$("#emitters_div").show();
|
||||||
|
$("#external_div").hide();
|
||||||
|
$("#dests_div").attr('class', 'col-md-4');
|
||||||
|
$("#dest_title").text("{% trans "Select receivers" %}");
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#type_credit").click(function() {
|
||||||
|
$("#emitters_div").hide();
|
||||||
|
$("#external_div").show();
|
||||||
|
$("#dests_div").attr('class', 'col-md-4');
|
||||||
|
$("#dest_title").text("{% trans "Credit note" %}");
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#type_debit").click(function() {
|
||||||
|
$("#emitters_div").hide();
|
||||||
|
$("#external_div").show();
|
||||||
|
$("#dests_div").attr('class', 'col-md-4');
|
||||||
|
$("#dest_title").text("{% trans "Debit note" %}");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script src="/static/js/transfer.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
<td><a href="{{object.get_absolute_url}}">{{ object.name }}</a></td>
|
<td><a href="{{object.get_absolute_url}}">{{ object.name }}</a></td>
|
||||||
<td>{{ object.destination }}</td>
|
<td>{{ object.destination }}</td>
|
||||||
<td>{{ object.amount | pretty_money }}</td>
|
<td>{{ object.amount | pretty_money }}</td>
|
||||||
<td>{{ object.template_type }}</td>
|
<td>{{ object.category }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
|
2
tox.ini
2
tox.ini
@ -10,7 +10,6 @@ setenv =
|
|||||||
PYTHONWARNINGS = all
|
PYTHONWARNINGS = all
|
||||||
deps =
|
deps =
|
||||||
-r{toxinidir}/requirements/base.txt
|
-r{toxinidir}/requirements/base.txt
|
||||||
-r{toxinidir}/requirements/api.txt
|
|
||||||
-r{toxinidir}/requirements/cas.txt
|
-r{toxinidir}/requirements/cas.txt
|
||||||
-r{toxinidir}/requirements/production.txt
|
-r{toxinidir}/requirements/production.txt
|
||||||
coverage
|
coverage
|
||||||
@ -22,7 +21,6 @@ commands =
|
|||||||
[testenv:linters]
|
[testenv:linters]
|
||||||
deps =
|
deps =
|
||||||
-r{toxinidir}/requirements/base.txt
|
-r{toxinidir}/requirements/base.txt
|
||||||
-r{toxinidir}/requirements/api.txt
|
|
||||||
-r{toxinidir}/requirements/cas.txt
|
-r{toxinidir}/requirements/cas.txt
|
||||||
-r{toxinidir}/requirements/production.txt
|
-r{toxinidir}/requirements/production.txt
|
||||||
flake8
|
flake8
|
||||||
|
Loading…
Reference in New Issue
Block a user