1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2024-11-26 18:37:12 +00:00

Merge branch 'master' into manage_button

This commit is contained in:
Pierre-antoine Comby 2020-03-23 15:31:39 +01:00
commit bbda99a3ac
74 changed files with 3935 additions and 982 deletions

13
.env_example Normal file
View File

@ -0,0 +1,13 @@
DJANGO_APP_STAGE="dev"
# Only used in dev mode, change to "postgresql" if you want to use PostgreSQL in dev
DJANGO_DEV_STORE_METHOD="sqllite"
DJANGO_DB_HOST="localhost"
DJANGO_DB_NAME="note_db"
DJANGO_DB_USER="note"
DJANGO_DB_PASSWORD="CHANGE_ME"
DJANGO_DB_PORT=""
DJANGO_SECRET_KEY="CHANGE_ME"
DJANGO_SETTINGS_MODULE="note_kfet.settings"
DOMAIN="localhost"
CONTACT_EMAIL="tresorerie.bde@localhost"
NOTE_URL="localhost"

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "apps/scripts"]
path = apps/scripts
url = git@gitlab.crans.org:bde/nk20-scripts.git

View File

@ -9,13 +9,13 @@ RUN apt update && \
apt install -y gettext nginx uwsgi uwsgi-plugin-python3 && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt /code/
COPY . /code/
# Comment what is not needed
RUN pip install -r requirements/base.txt
RUN pip install -r requirements/api.txt
RUN pip install -r requirements/cas.txt
RUN pip install -r requirements/production.txt
COPY . /code/
ENTRYPOINT ["/code/entrypoint.sh"]
EXPOSE 8000

View File

@ -32,6 +32,7 @@ On supposera pour la suite que vous utiliser debian/ubuntu sur un serveur tout n
$ python3 -m venv env
$ source env/bin/activate
(env)$ pip3 install -r requirements/base.txt
(env)$ pip3 install -r requirements/prod.txt # uniquement en prod, nécessite un base postgres
(env)$ deactivate
4. uwsgi et Nginx
@ -40,13 +41,12 @@ On supposera pour la suite que vous utiliser debian/ubuntu sur un serveur tout n
$ cp nginx_note.conf_example nginx_note.conf
***Modifier le fichier pour être en accord avec le reste de votre config***
***Modifier le fichier pour être en accord avec le reste de votre config***
On utilise uwsgi et Nginx pour gérer le coté serveur :
$ sudo ln -sf /var/www/note_kfet/nginx_note.conf /etc/nginx/sites-enabled/
Si l'on a un emperor (plusieurs instance uwsgi):
$ sudo ln -sf /var/www/note_kfet/uwsgi_note.ini /etc/uwsgi/sites/
@ -85,7 +85,7 @@ On supposera pour la suite que vous utiliser debian/ubuntu sur un serveur tout n
postgres=# CREATE DATABASE note_db OWNER note;
CREATE DATABASE
Si tout va bien:
Si tout va bien :
postgres=#\list
List of databases
@ -97,21 +97,28 @@ On supposera pour la suite que vous utiliser debian/ubuntu sur un serveur tout n
template1 | postgres | UTF8 | fr_FR.UTF-8 | fr_FR.UTF-8 | =c/postgres +postgres=CTc/postgres
(4 rows)
Dans un fichier `.env` à la racine du projet on renseigne des secrets:
DJANGO_APP_STAGE='prod'
DJANGO_DB_PASSWORD='le_mot_de_passe_de_la_bdd'
DJANGO_SECRET_KEY='une_secret_key_longue_et_compliquee'
ALLOWED_HOSTS='le_ndd_de_votre_instance'
6. Variable d'environnement et Migrations
On copie le fichier `.env_example` vers le fichier `.env` à la racine du projet
et on renseigne des secrets et des paramètres :
Ensuite on (re)bascule dans l'environement virtuel et on lance les migrations
DJANGO_APP_STAGE="dev" # ou "prod"
DJANGO_DEV_STORE_METHOD="sqllite" # ou "postgres"
DJANGO_DB_HOST="localhost"
DJANGO_DB_NAME="note_db"
DJANGO_DB_USER="note"
DJANGO_DB_PASSWORD="CHANGE_ME"
DJANGO_DB_PORT=""
DJANGO_SECRET_KEY="CHANGE_ME"
DJANGO_SETTINGS_MODULE="note_kfet.settings"
DOMAIN="localhost" # note.example.com
CONTACT_EMAIL="tresorerie.bde@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
$ source /env/bin/activate
(env)$ ./manage.py check # pas de bétise qui traine
(env)$ ./manage.py check # pas de bêtise qui traine
(env)$ ./manage.py makemigrations
(env)$ ./manage.py migrate
@ -126,17 +133,21 @@ Il est possible de travailler sur une instance Docker.
$ git clone git@gitlab.crans.org:bde/nk20.git
2. Dans le fichier `docker_compose.yml`, qu'on suppose déjà configuré,
2. Copiez le fichier `.env_example` à la racine du projet vers le fichier `.env`,
et mettez à jour vos variables d'environnement
3. Dans le fichier `docker_compose.yml`, qu'on suppose déjà configuré,
ajouter les lignes suivantes, en les adaptant à la configuration voulue :
nk20:
build: /chemin/vers/nk20
volumes:
- /chemin/vers/nk20:/code/
env_file: /chemin/vers/nk20/.env
restart: always
labels:
- traefik.domain=ndd.exemple.com
- traefik.frontend.rule=Host:ndd.exemple.com
- traefik.domain=ndd.example.com
- traefik.frontend.rule=Host:ndd.example.com
- traefik.port=8000
3. Enjoy :
@ -157,19 +168,22 @@ un serveur de développement par exemple sur son ordinateur.
$ python3 -m venv venv
$ source venv/bin/activate
(env)$ pip install -r requirements.txt
(env)$ pip install -r requirements/base.txt
3. Migrations et chargement des données initiales :
3. Copier le fichier `.env_example` vers `.env` à la racine du projet et mettre à jour
ce qu'il faut
4. Migrations et chargement des données initiales :
(env)$ ./manage.py makemigrations
(env)$ ./manage.py migrate
(env)$ ./manage.py loaddata initial
4. Créer un super-utilisateur :
5. Créer un super-utilisateur :
(env)$ ./manage.py createsuperuser
5. Enjoy :
6. Enjoy :
(env)$ ./manage.py runserver 0.0.0.0:8000
@ -184,4 +198,4 @@ Il est disponible [ici](https://wiki.crans.org/NoteKfet/NoteKfet2018/CdC).
## Documentation
La documentation est générée par django et son module admindocs.
**Commenter votre code !**
**Commentez votre code !**

View File

@ -1,13 +1,15 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from rest_framework import viewsets
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter
from api.viewsets import ReadProtectedModelViewSet
from .serializers import ActivityTypeSerializer, ActivitySerializer, GuestSerializer
from ..models import ActivityType, Activity, Guest
class ActivityTypeViewSet(viewsets.ModelViewSet):
class ActivityTypeViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `ActivityType` objects, serialize it to JSON with the given serializer,
@ -15,9 +17,11 @@ class ActivityTypeViewSet(viewsets.ModelViewSet):
"""
queryset = ActivityType.objects.all()
serializer_class = ActivityTypeSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['name', 'can_invite', ]
class ActivityViewSet(viewsets.ModelViewSet):
class ActivityViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Activity` objects, serialize it to JSON with the given serializer,
@ -25,9 +29,11 @@ class ActivityViewSet(viewsets.ModelViewSet):
"""
queryset = Activity.objects.all()
serializer_class = ActivitySerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['name', 'description', 'activity_type', ]
class GuestViewSet(viewsets.ModelViewSet):
class GuestViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Guest` objects, serialize it to JSON with the given serializer,
@ -35,3 +41,5 @@ class GuestViewSet(viewsets.ModelViewSet):
"""
queryset = Guest.objects.all()
serializer_class = GuestSerializer
filter_backends = [SearchFilter]
search_fields = ['$name', ]

View File

@ -3,10 +3,17 @@
from django.conf.urls import url, include
from django.contrib.auth.models import User
from rest_framework import routers, serializers, viewsets
from django.contrib.contenttypes.models import ContentType
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import routers, serializers
from rest_framework.filters import SearchFilter
from rest_framework.viewsets import ReadOnlyModelViewSet
from activity.api.urls import register_activity_urls
from api.viewsets import ReadProtectedModelViewSet
from member.api.urls import register_members_urls
from note.api.urls import register_note_urls
from logs.api.urls import register_logs_urls
from permission.api.urls import register_permission_urls
class UserSerializer(serializers.ModelSerializer):
@ -24,7 +31,18 @@ class UserSerializer(serializers.ModelSerializer):
)
class UserViewSet(viewsets.ModelViewSet):
class ContentTypeSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Users.
The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
"""
class Meta:
model = ContentType
fields = '__all__'
class UserViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
@ -32,15 +50,32 @@ class UserViewSet(viewsets.ModelViewSet):
"""
queryset = User.objects.all()
serializer_class = UserSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_superuser', 'is_staff', 'is_active', ]
search_fields = ['$username', '$first_name', '$last_name', ]
# This ViewSet is the only one that is accessible from all authenticated users!
class ContentTypeViewSet(ReadOnlyModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
then render it on /api/users/
"""
queryset = ContentType.objects.all()
serializer_class = ContentTypeSerializer
# Routers provide an easy way of automatically determining the URL conf.
# Register each app API router and user viewset
router = routers.DefaultRouter()
router.register('models', ContentTypeViewSet)
router.register('user', UserViewSet)
register_members_urls(router, 'members')
register_activity_urls(router, 'activity')
register_note_urls(router, 'note')
register_permission_urls(router, 'permission')
register_logs_urls(router, 'logs')
app_name = 'api'

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

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

View File

View File

@ -0,0 +1,19 @@
# 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 Changelog
class ChangelogSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Changelog types.
The djangorestframework plugin will analyse the model `Changelog` and parse all fields in the API.
"""
class Meta:
model = Changelog
fields = '__all__'
# noinspection PyProtectedMember
read_only_fields = [f.name for f in model._meta.get_fields()] # Changelogs are read-only protected

11
apps/logs/api/urls.py Normal file
View File

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

23
apps/logs/api/views.py Normal file
View File

@ -0,0 +1,23 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import OrderingFilter
from api.viewsets import ReadOnlyProtectedModelViewSet
from .serializers import ChangelogSerializer
from ..models import Changelog
class ChangelogViewSet(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 = Changelog.objects.all()
serializer_class = ChangelogSerializer
filter_backends = [DjangoFilterBackend, OrderingFilter]
filterset_fields = ['model', 'action', "instance_pk", 'user', 'ip', ]
ordering_fields = ['timestamp', ]
ordering = ['-timestamp', ]

View File

@ -2,6 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
from django.db.models.signals import pre_save, post_save, post_delete
from django.utils.translation import gettext_lazy as _
@ -11,4 +12,7 @@ class LogsConfig(AppConfig):
def ready(self):
# noinspection PyUnresolvedReferences
import logs.signals
from . import signals
pre_save.connect(signals.pre_save_object)
post_save.connect(signals.save_object)
post_delete.connect(signals.delete_object)

View File

@ -56,6 +56,12 @@ class Changelog(models.Model):
max_length=16,
null=False,
blank=False,
choices=[
('create', _('create')),
('edit', _('edit')),
('delete', _('delete')),
],
default='edit',
verbose_name=_('action'),
)

View File

@ -1,67 +1,39 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import inspect
from django.contrib.contenttypes.models import ContentType
from django.core import serializers
from django.db.models.signals import pre_save, post_save, post_delete
from django.dispatch import receiver
from rest_framework.renderers import JSONRenderer
from rest_framework.serializers import ModelSerializer
from note.models import NoteUser, Alias
from note_kfet.middlewares import get_current_authenticated_user, get_current_ip
from .models import Changelog
def get_request_in_signal(sender):
req = None
for entry in reversed(inspect.stack()):
try:
req = entry[0].f_locals['request']
# Check if there is a user
# noinspection PyStatementEffect
req.user
break
except:
pass
if not req:
print("WARNING: Attempt to save " + str(sender) + " with no user")
return req
def get_user_and_ip(sender):
req = get_request_in_signal(sender)
try:
user = req.user
if 'HTTP_X_FORWARDED_FOR' in req.META:
ip = req.META.get('HTTP_X_FORWARDED_FOR')
else:
ip = req.META.get('REMOTE_ADDR')
except:
user = None
ip = None
return user, ip
import getpass
# Ces modèles ne nécessitent pas de logs
EXCLUDED = [
'admin.logentry',
'authtoken.token',
'cas_server.proxygrantingticket',
'cas_server.proxyticket',
'cas_server.serviceticket',
'cas_server.user',
'cas_server.userattributes',
'contenttypes.contenttype',
'logs.changelog',
'logs.changelog', # Never remove this line
'migrations.migration',
'note.noteuser',
'note.noteclub',
'note.notespecial',
'note.note' # We only store the subclasses
'note.transaction',
'sessions.session',
'reversion.revision',
'reversion.version',
]
@receiver(pre_save)
def pre_save_object(sender, instance, **kwargs):
"""
Before a model get saved, we get the previous instance that is currently in the database
"""
qs = sender.objects.filter(pk=instance.pk).all()
if qs.exists():
instance._previous = qs.get()
@ -69,30 +41,51 @@ def pre_save_object(sender, instance, **kwargs):
instance._previous = None
@receiver(post_save)
def save_object(sender, instance, **kwargs):
"""
Each time a model is saved, an entry in the table `Changelog` is added in the database
in order to store each modification made
"""
# noinspection PyProtectedMember
if instance._meta.label_lower in EXCLUDED:
return
# noinspection PyProtectedMember
previous = instance._previous
user, ip = get_user_and_ip(sender)
# Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP
user, ip = get_current_authenticated_user(), get_current_ip()
from django.contrib.auth.models import AnonymousUser
if isinstance(user, AnonymousUser):
user = None
if user is None:
# Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py`
# On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée
# IMPORTANT : l'utilisateur dans la VM doit être un des alias note du respo info
ip = "127.0.0.1"
username = Alias.normalize(getpass.getuser())
note = NoteUser.objects.filter(alias__normalized_name=username)
# if not note.exists():
# print("WARNING: A model attempted to be saved in the DB, but the actor is unknown: " + username)
# else:
if note.exists():
user = note.get().user
# noinspection PyProtectedMember
if user is not None and instance._meta.label_lower == "auth.user" and previous:
# Don't save last login modifications
# On n'enregistre pas les connexions
if instance.last_login != previous.last_login:
return
previous_json = serializers.serialize('json', [previous, ])[1:-1] if previous else None
instance_json = serializers.serialize('json', [instance, ])[1:-1]
# On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles
class CustomSerializer(ModelSerializer):
class Meta:
model = instance.__class__
fields = '__all__'
previous_json = JSONRenderer().render(CustomSerializer(previous).data).decode("UTF-8") if previous else None
instance_json = JSONRenderer().render(CustomSerializer(instance).data).decode("UTF-8")
if previous_json == instance_json:
# No modification
# Pas de log s'il n'y a pas de modification
return
Changelog.objects.create(user=user,
@ -105,15 +98,38 @@ def save_object(sender, instance, **kwargs):
).save()
@receiver(post_delete)
def delete_object(sender, instance, **kwargs):
"""
Each time a model is deleted, an entry in the table `Changelog` is added in the database
"""
# noinspection PyProtectedMember
if instance._meta.label_lower in EXCLUDED:
return
user, ip = get_user_and_ip(sender)
# Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP
user, ip = get_current_authenticated_user(), get_current_ip()
if user is None:
# Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py`
# On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée
# IMPORTANT : l'utilisateur dans la VM doit être un des alias note du respo info
ip = "127.0.0.1"
username = Alias.normalize(getpass.getuser())
note = NoteUser.objects.filter(alias__normalized_name=username)
# if not note.exists():
# print("WARNING: A model attempted to be saved in the DB, but the actor is unknown: " + username)
# else:
if note.exists():
user = note.get().user
# On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles
class CustomSerializer(ModelSerializer):
class Meta:
model = instance.__class__
fields = '__all__'
instance_json = JSONRenderer().render(CustomSerializer(instance).data).decode("UTF-8")
instance_json = serializers.serialize('json', [instance, ])[1:-1]
Changelog.objects.create(user=user,
ip=ip,
model=ContentType.objects.get_for_model(instance),

View File

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

View File

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

View File

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

View File

@ -1,6 +1,8 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import datetime
from django.conf import settings
from django.db import models
from django.urls import reverse, reverse_lazy
@ -46,6 +48,7 @@ class Profile(models.Model):
class Meta:
verbose_name = _('user profile')
verbose_name_plural = _('user profile')
indexes = [models.Index(fields=['user'])]
def get_absolute_url(self):
return reverse('user_detail', args=(self.pk,))
@ -149,15 +152,13 @@ class Membership(models.Model):
verbose_name=_('fee'),
)
def valid(self):
if self.date_end is not None:
return self.date_start.toordinal() <= datetime.datetime.now().toordinal() < self.date_end.toordinal()
else:
return self.date_start.toordinal() <= datetime.datetime.now().toordinal()
class Meta:
verbose_name = _('membership')
verbose_name_plural = _('memberships')
# @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()
indexes = [models.Index(fields=['user'])]

View File

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

View File

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

View File

@ -5,7 +5,8 @@ from rest_framework import serializers
from rest_polymorphic.serializers import PolymorphicSerializer
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias
from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction
from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory, \
RecurrentTransaction, SpecialTransaction
class NoteSerializer(serializers.ModelSerializer):
@ -17,12 +18,7 @@ class NoteSerializer(serializers.ModelSerializer):
class Meta:
model = Note
fields = '__all__'
extra_kwargs = {
'url': {
'view_name': 'project-detail',
'lookup_field': 'pk'
},
}
read_only_fields = [f.name for f in model._meta.get_fields()] # Notes are read-only protected
class NoteClubSerializer(serializers.ModelSerializer):
@ -30,10 +26,15 @@ class NoteClubSerializer(serializers.ModelSerializer):
REST API Serializer for Club's notes.
The djangorestframework plugin will analyse the model `NoteClub` and parse all fields in the API.
"""
name = serializers.SerializerMethodField()
class Meta:
model = NoteClub
fields = '__all__'
read_only_fields = ('note', 'club', )
def get_name(self, obj):
return str(obj)
class NoteSpecialSerializer(serializers.ModelSerializer):
@ -41,10 +42,15 @@ class NoteSpecialSerializer(serializers.ModelSerializer):
REST API Serializer for special notes.
The djangorestframework plugin will analyse the model `NoteSpecial` and parse all fields in the API.
"""
name = serializers.SerializerMethodField()
class Meta:
model = NoteSpecial
fields = '__all__'
read_only_fields = ('note', )
def get_name(self, obj):
return str(obj)
class NoteUserSerializer(serializers.ModelSerializer):
@ -52,10 +58,15 @@ class NoteUserSerializer(serializers.ModelSerializer):
REST API Serializer for User's notes.
The djangorestframework plugin will analyse the model `NoteUser` and parse all fields in the API.
"""
name = serializers.SerializerMethodField()
class Meta:
model = NoteUser
fields = '__all__'
read_only_fields = ('note', 'user', )
def get_name(self, obj):
return str(obj)
class AliasSerializer(serializers.ModelSerializer):
@ -67,6 +78,7 @@ class AliasSerializer(serializers.ModelSerializer):
class Meta:
model = Alias
fields = '__all__'
read_only_fields = ('note', )
class NotePolymorphicSerializer(PolymorphicSerializer):
@ -77,6 +89,20 @@ class NotePolymorphicSerializer(PolymorphicSerializer):
NoteSpecial: NoteSpecialSerializer
}
class Meta:
model = Note
class TemplateCategorySerializer(serializers.ModelSerializer):
"""
REST API Serializer for Transaction templates.
The djangorestframework plugin will analyse the model `TemplateCategory` and parse all fields in the API.
"""
class Meta:
model = TemplateCategory
fields = '__all__'
class TransactionTemplateSerializer(serializers.ModelSerializer):
"""
@ -100,6 +126,17 @@ class TransactionSerializer(serializers.ModelSerializer):
fields = '__all__'
class RecurrentTransactionSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Transactions.
The djangorestframework plugin will analyse the model `RecurrentTransaction` and parse all fields in the API.
"""
class Meta:
model = RecurrentTransaction
fields = '__all__'
class MembershipTransactionSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Membership transactions.
@ -109,3 +146,26 @@ class MembershipTransactionSerializer(serializers.ModelSerializer):
class Meta:
model = MembershipTransaction
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):
model_serializer_mapping = {
Transaction: TransactionSerializer,
RecurrentTransaction: RecurrentTransactionSerializer,
MembershipTransaction: MembershipTransactionSerializer,
SpecialTransaction: SpecialTransactionSerializer,
}
class Meta:
model = Transaction

View File

@ -2,7 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from .views import NotePolymorphicViewSet, AliasViewSet, \
TransactionViewSet, TransactionTemplateViewSet, MembershipTransactionViewSet
TemplateCategoryViewSet, TransactionViewSet, TransactionTemplateViewSet
def register_note_urls(router, path):
@ -12,6 +12,6 @@ def register_note_urls(router, path):
router.register(path + '/note', NotePolymorphicViewSet)
router.register(path + '/alias', AliasViewSet)
router.register(path + '/transaction/category', TemplateCategoryViewSet)
router.register(path + '/transaction/transaction', TransactionViewSet)
router.register(path + '/transaction/template', TransactionTemplateViewSet)
router.register(path + '/transaction/membership', MembershipTransactionViewSet)

View File

@ -2,56 +2,17 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from django.db.models import Q
from rest_framework import viewsets
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import OrderingFilter, SearchFilter
from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet
from .serializers import NoteSerializer, NotePolymorphicSerializer, NoteClubSerializer, NoteSpecialSerializer, \
NoteUserSerializer, AliasSerializer, \
TransactionTemplateSerializer, TransactionSerializer, MembershipTransactionSerializer
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias
from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction
from .serializers import NotePolymorphicSerializer, AliasSerializer, TemplateCategorySerializer, \
TransactionTemplateSerializer, TransactionPolymorphicSerializer
from ..models.notes import Note, Alias
from ..models.transactions import TransactionTemplate, Transaction, TemplateCategory
class NoteViewSet(viewsets.ModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Note` objects, serialize it to JSON with the given serializer,
then render it on /api/note/note/
"""
queryset = Note.objects.all()
serializer_class = NoteSerializer
class NoteClubViewSet(viewsets.ModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `NoteClub` objects, serialize it to JSON with the given serializer,
then render it on /api/note/club/
"""
queryset = NoteClub.objects.all()
serializer_class = NoteClubSerializer
class NoteSpecialViewSet(viewsets.ModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `NoteSpecial` objects, serialize it to JSON with the given serializer,
then render it on /api/note/special/
"""
queryset = NoteSpecial.objects.all()
serializer_class = NoteSpecialSerializer
class NoteUserViewSet(viewsets.ModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `NoteUser` objects, serialize it to JSON with the given serializer,
then render it on /api/note/user/
"""
queryset = NoteUser.objects.all()
serializer_class = NoteUserSerializer
class NotePolymorphicViewSet(viewsets.ModelViewSet):
class NotePolymorphicViewSet(ReadOnlyProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Note` objects (with polymorhism), serialize it to JSON with the given serializer,
@ -59,36 +20,27 @@ class NotePolymorphicViewSet(viewsets.ModelViewSet):
"""
queryset = Note.objects.all()
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):
"""
Parse query and apply filters.
:return: The filtered set of requested notes
"""
queryset = Note.objects.all()
queryset = super().get_queryset()
alias = self.request.query_params.get("alias", ".*")
queryset = queryset.filter(
Q(alias__name__regex=alias)
| Q(alias__normalized_name__regex=alias.lower()))
Q(alias__name__regex="^" + alias)
| Q(alias__normalized_name__regex="^" + Alias.normalize(alias))
| Q(alias__normalized_name__regex="^" + alias.lower()))
note_type = self.request.query_params.get("type", None)
if note_type:
types = str(note_type).lower()
if "user" in types:
queryset = queryset.filter(polymorphic_ctype__model="noteuser")
elif "club" in types:
queryset = queryset.filter(polymorphic_ctype__model="noteclub")
elif "special" in types:
queryset = queryset.filter(
polymorphic_ctype__model="notespecial")
else:
queryset = queryset.none()
return queryset
return queryset.distinct()
class AliasViewSet(viewsets.ModelViewSet):
class AliasViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Alias` objects, serialize it to JSON with the given serializer,
@ -96,6 +48,9 @@ class AliasViewSet(viewsets.ModelViewSet):
"""
queryset = Alias.objects.all()
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):
"""
@ -103,35 +58,30 @@ class AliasViewSet(viewsets.ModelViewSet):
:return: The filtered set of requested aliases
"""
queryset = Alias.objects.all()
queryset = super().get_queryset()
alias = self.request.query_params.get("alias", ".*")
queryset = queryset.filter(
Q(name__regex=alias) | Q(normalized_name__regex=alias.lower()))
note_id = self.request.query_params.get("note", None)
if note_id:
queryset = queryset.filter(id=note_id)
note_type = self.request.query_params.get("type", None)
if note_type:
types = str(note_type).lower()
if "user" in types:
queryset = queryset.filter(
note__polymorphic_ctype__model="noteuser")
elif "club" in types:
queryset = queryset.filter(
note__polymorphic_ctype__model="noteclub")
elif "special" in types:
queryset = queryset.filter(
note__polymorphic_ctype__model="notespecial")
else:
queryset = queryset.none()
Q(name__regex="^" + alias)
| Q(normalized_name__regex="^" + Alias.normalize(alias))
| Q(normalized_name__regex="^" + alias.lower()))
return queryset
class TransactionTemplateViewSet(viewsets.ModelViewSet):
class TemplateCategoryViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `TemplateCategory` objects, serialize it to JSON with the given serializer,
then render it on /api/note/transaction/category/
"""
queryset = TemplateCategory.objects.all()
serializer_class = TemplateCategorySerializer
filter_backends = [SearchFilter]
search_fields = ['$name', ]
class TransactionTemplateViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `TransactionTemplate` objects, serialize it to JSON with the given serializer,
@ -139,23 +89,17 @@ class TransactionTemplateViewSet(viewsets.ModelViewSet):
"""
queryset = TransactionTemplate.objects.all()
serializer_class = TransactionTemplateSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['name', 'amount', 'display', 'category', ]
class TransactionViewSet(viewsets.ModelViewSet):
class TransactionViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Transaction` objects, serialize it to JSON with the given serializer,
then render it on /api/note/transaction/transaction/
"""
queryset = Transaction.objects.all()
serializer_class = TransactionSerializer
class MembershipTransactionViewSet(viewsets.ModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `MembershipTransaction` objects, serialize it to JSON with the given serializer,
then render it on /api/note/transaction/membership/
"""
queryset = MembershipTransaction.objects.all()
serializer_class = MembershipTransactionSerializer
serializer_class = TransactionPolymorphicSerializer
filter_backends = [SearchFilter]
search_fields = ['$reason', ]

View File

@ -3,8 +3,12 @@
"model": "note.note",
"pk": 1,
"fields": {
"polymorphic_ctype": 39,
"polymorphic_ctype": [
"note",
"notespecial"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:02:48.778Z"
@ -14,8 +18,12 @@
"model": "note.note",
"pk": 2,
"fields": {
"polymorphic_ctype": 39,
"polymorphic_ctype": [
"note",
"notespecial"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:06:39.546Z"
@ -25,8 +33,12 @@
"model": "note.note",
"pk": 3,
"fields": {
"polymorphic_ctype": 39,
"polymorphic_ctype": [
"note",
"notespecial"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:06:43.049Z"
@ -36,8 +48,12 @@
"model": "note.note",
"pk": 4,
"fields": {
"polymorphic_ctype": 39,
"polymorphic_ctype": [
"note",
"notespecial"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:06:50.996Z"
@ -47,8 +63,12 @@
"model": "note.note",
"pk": 5,
"fields": {
"polymorphic_ctype": 38,
"polymorphic_ctype": [
"note",
"noteclub"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:09:38.615Z"
@ -58,13 +78,46 @@
"model": "note.note",
"pk": 6,
"fields": {
"polymorphic_ctype": 38,
"polymorphic_ctype": [
"note",
"noteclub"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:16:14.753Z"
}
},
{
"model": "note.note",
"pk": 7,
"fields": {
"polymorphic_ctype": [
"note",
"noteuser"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "pic/default.png",
"created_at": "2020-03-22T13:01:35.680Z"
}
},
{
"model": "note.noteclub",
"pk": 5,
"fields": {
"club": 1
}
},
{
"model": "note.noteclub",
"pk": 6,
"fields": {
"club": 2
}
},
{
"model": "note.notespecial",
"pk": 1,
@ -93,20 +146,6 @@
"special_type": "Virement bancaire"
}
},
{
"model": "note.noteclub",
"pk": 5,
"fields": {
"club": 1
}
},
{
"model": "note.noteclub",
"pk": 6,
"fields": {
"club": 2
}
},
{
"model": "note.alias",
"pk": 1,

View File

@ -6,7 +6,7 @@ from django import forms
from django.utils.translation import gettext_lazy as _
from .models import Alias
from .models import Transaction, TransactionTemplate, TemplateTransaction
from .models import TransactionTemplate
class AliasForm(forms.ModelForm):
@ -50,82 +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,
},
),
}
class ConsoForm(forms.ModelForm):
def save(self, commit=True):
button: TransactionTemplate = TransactionTemplate.objects.filter(
name=self.data['button']).get()
self.instance.destination = button.destination
self.instance.amount = button.amount
self.instance.reason = '{} ({})'.format(button.name, button.category)
self.instance.template = button
self.instance.category = button.category
super().save(commit)
class Meta:
model = TemplateTransaction
fields = ('source',)
# Le champ d'utilisateur est remplacé par un champ d'auto-complétion.
# Quand des lettres sont tapées, une requête est envoyée sur l'API d'auto-complétion
# et récupère les aliases de note valides
widgets = {
'source':
autocomplete.ModelSelect2(
url='note:note_autocomplete',
attrs={
'data-placeholder': 'Note ...',
'data-minimum-input-length': 1,
},
),
}

View File

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

View File

@ -209,6 +209,10 @@ class Alias(models.Model):
class Meta:
verbose_name = _("alias")
verbose_name_plural = _("aliases")
indexes = [
models.Index(fields=['name']),
models.Index(fields=['normalized_name']),
]
def __str__(self):
return self.name
@ -231,7 +235,7 @@ class Alias(models.Model):
try:
sim_alias = Alias.objects.get(normalized_name=normalized_name)
if self != sim_alias:
raise ValidationError(_('An alias with a similar name already exists: {} '.format(sim_alias)),
raise ValidationError(_('An alias with a similar name already exists: {} ').format(sim_alias),
code="same_alias"
)
except Alias.DoesNotExist:

View File

@ -7,7 +7,7 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from polymorphic.models import PolymorphicModel
from .notes import Note, NoteClub
from .notes import Note, NoteClub, NoteSpecial
"""
Defines transactions
@ -68,6 +68,7 @@ class TransactionTemplate(models.Model):
description = models.CharField(
verbose_name=_('description'),
max_length=255,
blank=True,
)
class Meta:
@ -106,7 +107,10 @@ class Transaction(PolymorphicModel):
verbose_name=_('quantity'),
default=1,
)
amount = models.PositiveIntegerField(verbose_name=_('amount'), )
amount = models.PositiveIntegerField(
verbose_name=_('amount'),
)
reason = models.CharField(
verbose_name=_('reason'),
max_length=255,
@ -119,6 +123,11 @@ class Transaction(PolymorphicModel):
class Meta:
verbose_name = _("transaction")
verbose_name_plural = _("transactions")
indexes = [
models.Index(fields=['created_at']),
models.Index(fields=['source']),
models.Index(fields=['destination']),
]
def save(self, *args, **kwargs):
"""
@ -127,6 +136,7 @@ class Transaction(PolymorphicModel):
if self.source.pk == self.destination.pk:
# When source == destination, no money is transfered
super().save(*args, **kwargs)
return
created = self.pk is None
@ -142,20 +152,25 @@ class Transaction(PolymorphicModel):
self.source.balance -= to_transfer
self.destination.balance += to_transfer
# We save first the transaction, in case of the user has no right to transfer money
super().save(*args, **kwargs)
# Save notes
self.source.save()
self.destination.save()
super().save(*args, **kwargs)
@property
def total(self):
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`.
"""
template = models.ForeignKey(
@ -168,6 +183,36 @@ class TemplateTransaction(Transaction):
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):
"""
@ -184,3 +229,7 @@ class MembershipTransaction(Transaction):
class Meta:
verbose_name = _("membership transaction")
verbose_name_plural = _("membership transactions")
@property
def type(self):
return _('membership transaction')

View File

@ -1,12 +1,15 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import html
import django_tables2 as tables
from django.db.models import F
from django_tables2.utils import A
from django.utils.translation import gettext_lazy as _
from .models.notes import Alias
from .models.transactions import Transaction, TransactionTemplate
from .models.transactions import Transaction
from .templatetags.pretty_money import pretty_money
@ -17,17 +20,25 @@ class HistoryTable(tables.Table):
'table table-condensed table-striped table-hover'
}
model = Transaction
exclude = ("polymorphic_ctype", )
exclude = ("id", "polymorphic_ctype", )
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() !!
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):
# needed for rendering
queryset = queryset.annotate(total=F('amount') * F('quantity')) \
.order_by(('-' if is_descending else '') + 'total')
return (queryset, True)
return queryset, True
def render_amount(self, value):
return pretty_money(value)
@ -35,6 +46,16 @@ class HistoryTable(tables.Table):
def render_total(self, 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 Meta:

View File

@ -0,0 +1,14 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django import template
import os
def getenv(value):
return os.getenv(value)
register = template.Library()
register.filter('getenv', getenv)

View File

@ -11,7 +11,7 @@ def pretty_money(value):
abs(value) // 100,
)
else:
return "{:s}{:d}{:02d}".format(
return "{:s}{:d}.{:02d}".format(
"- " if value < 0 else "",
abs(value) // 100,
abs(value) % 100,

View File

@ -3,16 +3,18 @@
from dal import autocomplete
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, ListView, UpdateView
from django_tables2 import SingleTableView
from permission.backends import PermissionBackend
from .forms import TransactionTemplateForm
from .models import Transaction, TransactionTemplate, Alias, RecurrentTransaction, NoteSpecial
from .models.transactions import SpecialTransaction
from .tables import HistoryTable
from .forms import TransactionForm, TransactionTemplateForm, ConsoForm
from .models import Transaction, TransactionTemplate, Alias, TemplateTransaction
from .tables import ButtonTable
class TransactionCreateView(LoginRequiredMixin, SingleTableView):
"""
@ -23,34 +25,27 @@ class TransactionCreateView(LoginRequiredMixin, SingleTableView):
model = Transaction
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):
"""
Add some context variables in template such as page title
"""
context = super().get_context_data(**kwargs)
context['title'] = _('Transfer money from your account '
'to one or others')
context['no_cache'] = True
context['title'] = _('Transfer money')
context['polymorphic_ctype'] = ContentType.objects.get_for_model(Transaction).pk
context['special_polymorphic_ctype'] = ContentType.objects.get_for_model(SpecialTransaction).pk
context['special_types'] = NoteSpecial.objects.order_by("special_type").all()
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):
"""
@ -71,7 +66,7 @@ class NoteAutocomplete(autocomplete.Select2QuerySetView):
# self.q est le paramètre de la recherche
if self.q:
qs = qs.filter(Q(name__regex=self.q) | Q(normalized_name__regex=Alias.normalize(self.q))) \
qs = qs.filter(Q(name__regex="^" + self.q) | Q(normalized_name__regex="^" + Alias.normalize(self.q))) \
.order_by('normalized_name').distinct()
# Filtrage par type de note (user, club, special)
@ -131,31 +126,37 @@ class TransactionTemplateUpdateView(LoginRequiredMixin, UpdateView):
form_class = TransactionTemplateForm
class ConsoView(LoginRequiredMixin, CreateView):
class ConsoView(LoginRequiredMixin, SingleTableView):
"""
The Magic View that make people pay their beer and burgers.
(Most of the magic happens in the dark world of Javascript see consos.js)
"""
model = TemplateTransaction
template_name = "note/conso_form.html"
form_class = ConsoForm
# Transaction history table
table_class = HistoryTable
table_pagination = {"per_page": 50}
def get_queryset(self):
return Transaction.objects.filter(
PermissionBackend.filter_queryset(self.request.user, Transaction, "view")
).order_by("-id").all()[:50]
def get_context_data(self, **kwargs):
"""
Add some context variables in template such as page title
"""
context = super().get_context_data(**kwargs)
context['transaction_templates'] = TransactionTemplate.objects.filter(display=True) \
.order_by('category')
context['title'] = _("Consommations")
from django.db.models import Count
buttons = TransactionTemplate.objects.filter(
PermissionBackend().filter_queryset(self.request.user, TransactionTemplate, "view")
).filter(display=True).annotate(clicks=Count('recurrenttransaction')).order_by('category__name', 'name')
context['transaction_templates'] = buttons
context['most_used'] = buttons.order_by('-clicks', 'name')[:10]
context['title'] = _("Consumptions")
context['polymorphic_ctype'] = ContentType.objects.get_for_model(RecurrentTransaction).pk
# select2 compatibility
context['no_cache'] = True
return context
def get_success_url(self):
"""
When clicking a button, reload the same page
"""
return reverse('note:consos')

View File

@ -1,8 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
app_name = 'logs'
# TODO User interface
urlpatterns = [
]
default_app_config = 'permission.apps.PermissionConfig'

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

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

View File

View File

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

View File

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

View File

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

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

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

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

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

View File

@ -0,0 +1,653 @@
[
{
"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": [
"auth",
"user"
],
"query": "{\"pk\": [\"user\", \"pk\"]}",
"type": "view",
"mask": 1,
"field": "",
"description": "View our User object"
}
},
{
"model": "permission.permission",
"pk": 2,
"fields": {
"model": [
"member",
"profile"
],
"query": "{\"user\": [\"user\"]}",
"type": "view",
"mask": 1,
"field": "",
"description": "View our profile"
}
},
{
"model": "permission.permission",
"pk": 3,
"fields": {
"model": [
"note",
"noteuser"
],
"query": "{\"pk\": [\"user\", \"note\", \"pk\"]}",
"type": "view",
"mask": 1,
"field": "",
"description": "View our own note"
}
},
{
"model": "permission.permission",
"pk": 4,
"fields": {
"model": [
"authtoken",
"token"
],
"query": "{\"user\": [\"user\"]}",
"type": "view",
"mask": 1,
"field": "",
"description": "View our API token"
}
},
{
"model": "permission.permission",
"pk": 5,
"fields": {
"model": [
"note",
"transaction"
],
"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": [
"note",
"alias"
],
"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": [
"auth",
"user"
],
"query": "{\"pk\": [\"user\", \"pk\"]}",
"type": "change",
"mask": 1,
"field": "last_login",
"description": "Change myself's last login"
}
},
{
"model": "permission.permission",
"pk": 8,
"fields": {
"model": [
"auth",
"user"
],
"query": "{\"pk\": [\"user\", \"pk\"]}",
"type": "change",
"mask": 1,
"field": "username",
"description": "Change myself's username"
}
},
{
"model": "permission.permission",
"pk": 9,
"fields": {
"model": [
"auth",
"user"
],
"query": "{\"pk\": [\"user\", \"pk\"]}",
"type": "change",
"mask": 1,
"field": "first_name",
"description": "Change myself's first name"
}
},
{
"model": "permission.permission",
"pk": 10,
"fields": {
"model": [
"auth",
"user"
],
"query": "{\"pk\": [\"user\", \"pk\"]}",
"type": "change",
"mask": 1,
"field": "last_name",
"description": "Change myself's last name"
}
},
{
"model": "permission.permission",
"pk": 11,
"fields": {
"model": [
"auth",
"user"
],
"query": "{\"pk\": [\"user\", \"pk\"]}",
"type": "change",
"mask": 1,
"field": "email",
"description": "Change myself's email"
}
},
{
"model": "permission.permission",
"pk": 12,
"fields": {
"model": [
"authtoken",
"token"
],
"query": "{\"user\": [\"user\"]}",
"type": "delete",
"mask": 1,
"field": "",
"description": "Delete API Token"
}
},
{
"model": "permission.permission",
"pk": 13,
"fields": {
"model": [
"authtoken",
"token"
],
"query": "{\"user\": [\"user\"]}",
"type": "add",
"mask": 1,
"field": "",
"description": "Create API Token"
}
},
{
"model": "permission.permission",
"pk": 14,
"fields": {
"model": [
"note",
"alias"
],
"query": "{\"note\": [\"user\", \"note\"]}",
"type": "delete",
"mask": 1,
"field": "",
"description": "Remove alias"
}
},
{
"model": "permission.permission",
"pk": 15,
"fields": {
"model": [
"note",
"alias"
],
"query": "{\"note\": [\"user\", \"note\"]}",
"type": "add",
"mask": 1,
"field": "",
"description": "Add alias"
}
},
{
"model": "permission.permission",
"pk": 16,
"fields": {
"model": [
"note",
"noteuser"
],
"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": [
"note",
"transaction"
],
"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": [
"note",
"note"
],
"query": "{}",
"type": "change",
"mask": 1,
"field": "balance",
"description": "Update a note balance with a transaction"
}
},
{
"model": "permission.permission",
"pk": 19,
"fields": {
"model": [
"note",
"note"
],
"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": [
"note",
"transaction"
],
"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": [
"note",
"recurrenttransaction"
],
"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": [
"member",
"club"
],
"query": "{\"pk\": [\"club\", \"pk\"]}",
"type": "view",
"mask": 1,
"field": "",
"description": "View club infos"
}
},
{
"model": "permission.permission",
"pk": 23,
"fields": {
"model": [
"note",
"transaction"
],
"query": "{}",
"type": "change",
"mask": 1,
"field": "valid",
"description": "Update validation status of a transaction"
}
},
{
"model": "permission.permission",
"pk": 24,
"fields": {
"model": [
"note",
"transaction"
],
"query": "{}",
"type": "view",
"mask": 2,
"field": "",
"description": "View all transactions"
}
},
{
"model": "permission.permission",
"pk": 25,
"fields": {
"model": [
"note",
"notespecial"
],
"query": "{}",
"type": "view",
"mask": 2,
"field": "",
"description": "Display credit/debit interface"
}
},
{
"model": "permission.permission",
"pk": 26,
"fields": {
"model": [
"note",
"specialtransaction"
],
"query": "{}",
"type": "add",
"mask": 2,
"field": "",
"description": "Create credit/debit transaction"
}
},
{
"model": "permission.permission",
"pk": 27,
"fields": {
"model": [
"note",
"templatecategory"
],
"query": "{}",
"type": "view",
"mask": 2,
"field": "",
"description": "View button categories"
}
},
{
"model": "permission.permission",
"pk": 28,
"fields": {
"model": [
"note",
"templatecategory"
],
"query": "{}",
"type": "change",
"mask": 3,
"field": "",
"description": "Change button category"
}
},
{
"model": "permission.permission",
"pk": 29,
"fields": {
"model": [
"note",
"templatecategory"
],
"query": "{}",
"type": "add",
"mask": 3,
"field": "",
"description": "Add button category"
}
},
{
"model": "permission.permission",
"pk": 30,
"fields": {
"model": [
"note",
"transactiontemplate"
],
"query": "{}",
"type": "view",
"mask": 2,
"field": "",
"description": "View buttons"
}
},
{
"model": "permission.permission",
"pk": 31,
"fields": {
"model": [
"note",
"transactiontemplate"
],
"query": "{}",
"type": "add",
"mask": 3,
"field": "",
"description": "Add buttons"
}
},
{
"model": "permission.permission",
"pk": 32,
"fields": {
"model": [
"note",
"transactiontemplate"
],
"query": "{}",
"type": "change",
"mask": 3,
"field": "",
"description": "Update buttons"
}
},
{
"model": "permission.permission",
"pk": 33,
"fields": {
"model": [
"note",
"transaction"
],
"query": "{}",
"type": "add",
"mask": 2,
"field": "",
"description": "Create any transaction"
}
},
{
"model": "permission.rolepermissions",
"pk": 1,
"fields": {
"role": 1,
"permissions": [
1,
2,
7,
8,
9,
10,
11
]
}
},
{
"model": "permission.rolepermissions",
"pk": 2,
"fields": {
"role": 2,
"permissions": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18
]
}
},
{
"model": "permission.rolepermissions",
"pk": 3,
"fields": {
"role": 8,
"permissions": [
19,
20,
21,
22
]
}
},
{
"model": "permission.rolepermissions",
"pk": 4,
"fields": {
"role": 4,
"permissions": [
23,
24,
25,
26,
27,
28,
29,
30,
31,
32,
33
]
}
}
]

View File

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

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

View File

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

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

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

View File

View File

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

View File

@ -2,12 +2,17 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
if [ -z ${NOTE_URL+x} ]; then
echo "Warning: your env files are not configurated."
else
sed -i -e "s/example.com/$DOMAIN/g" /code/apps/member/fixtures/initial.json
sed -i -e "s/localhost/$NOTE_URL/g" /code/note_kfet/fixtures/initial.json
sed -i -e "s/\"\.\*\"/\"https?:\/\/$NOTE_URL\/.*\"/g" /code/note_kfet/fixtures/cas.json
sed -i -e "s/REPLACEME/La Note Kfet \\\\ud83c\\\\udf7b/g" /code/note_kfet/fixtures/cas.json
fi
python manage.py compilemessages
python manage.py makemigrations
# Wait for database
sleep 5
python manage.py migrate
# TODO: use uwsgi in production
python manage.py runserver 0.0.0.0:8000

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-03-07 18:01+0100\n"
"POT-Creation-Date: 2020-03-16 11:53+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -23,9 +23,10 @@ msgid "activity"
msgstr ""
#: apps/activity/models.py:19 apps/activity/models.py:44
#: apps/member/models.py:60 apps/member/models.py:111
#: apps/note/models/notes.py:187 apps/note/models/transactions.py:24
#: apps/note/models/transactions.py:44 templates/member/profile_detail.html:15
#: 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/transactions.py:44 apps/note/models/transactions.py:202
#: templates/member/profile_detail.html:15
msgid "name"
msgstr ""
@ -49,8 +50,8 @@ msgstr ""
msgid "description"
msgstr ""
#: apps/activity/models.py:54 apps/note/models/notes.py:163
#: apps/note/models/transactions.py:62
#: apps/activity/models.py:54 apps/note/models/notes.py:164
#: apps/note/models/transactions.py:62 apps/note/models/transactions.py:115
msgid "type"
msgstr ""
@ -86,11 +87,11 @@ msgstr ""
msgid "API"
msgstr ""
#: apps/logs/apps.py:10
#: apps/logs/apps.py:11
msgid "Logs"
msgstr ""
#: apps/logs/models.py:21 apps/note/models/notes.py:116
#: apps/logs/models.py:21 apps/note/models/notes.py:117
msgid "user"
msgstr ""
@ -114,15 +115,27 @@ msgstr ""
msgid "new data"
msgstr ""
#: apps/logs/models.py:59
#: apps/logs/models.py:60
msgid "create"
msgstr ""
#: apps/logs/models.py:61
msgid "edit"
msgstr ""
#: apps/logs/models.py:62
msgid "delete"
msgstr ""
#: apps/logs/models.py:65
msgid "action"
msgstr ""
#: apps/logs/models.py:67
#: apps/logs/models.py:73
msgid "timestamp"
msgstr ""
#: apps/logs/models.py:71
#: apps/logs/models.py:77
msgid "Logs cannot be destroyed."
msgstr ""
@ -154,73 +167,73 @@ msgstr ""
msgid "user profile"
msgstr ""
#: apps/member/models.py:65
#: apps/member/models.py:66
msgid "email"
msgstr ""
#: apps/member/models.py:70
#: apps/member/models.py:71
msgid "membership fee"
msgstr ""
#: apps/member/models.py:74
#: apps/member/models.py:75
msgid "membership duration"
msgstr ""
#: apps/member/models.py:75
#: apps/member/models.py:76
msgid "The longest time a membership can last (NULL = infinite)."
msgstr ""
#: apps/member/models.py:80
#: apps/member/models.py:81
msgid "membership start"
msgstr ""
#: apps/member/models.py:81
#: apps/member/models.py:82
msgid "How long after January 1st the members can renew their membership."
msgstr ""
#: apps/member/models.py:86
#: apps/member/models.py:87
msgid "membership end"
msgstr ""
#: apps/member/models.py:87
#: apps/member/models.py:88
msgid ""
"How long the membership can last after January 1st of the next year after "
"members can renew their membership."
msgstr ""
#: apps/member/models.py:93 apps/note/models/notes.py:138
#: apps/member/models.py:94 apps/note/models/notes.py:139
msgid "club"
msgstr ""
#: apps/member/models.py:94
#: apps/member/models.py:95
msgid "clubs"
msgstr ""
#: apps/member/models.py:117
#: apps/member/models.py:118
msgid "role"
msgstr ""
#: apps/member/models.py:118
#: apps/member/models.py:119
msgid "roles"
msgstr ""
#: apps/member/models.py:142
#: apps/member/models.py:143
msgid "membership starts on"
msgstr ""
#: apps/member/models.py:145
#: apps/member/models.py:146
msgid "membership ends on"
msgstr ""
#: apps/member/models.py:149
#: apps/member/models.py:150
msgid "fee"
msgstr ""
#: apps/member/models.py:153
#: apps/member/models.py:154
msgid "membership"
msgstr ""
#: apps/member/models.py:154
#: apps/member/models.py:155
msgid "memberships"
msgstr ""
@ -237,140 +250,136 @@ msgstr ""
msgid "Account #%(id)s: %(username)s"
msgstr ""
#: apps/member/views.py:200
#: apps/member/views.py:202
msgid "Alias successfully deleted"
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"
msgstr ""
#: 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"
msgstr ""
#: apps/note/apps.py:14 apps/note/models/notes.py:57
#: apps/note/apps.py:14 apps/note/models/notes.py:58
msgid "note"
msgstr ""
#: apps/note/forms.py:26
#: apps/note/forms.py:20
msgid "New Alias"
msgstr ""
#: apps/note/forms.py:31
#: apps/note/forms.py:25
msgid "select an image"
msgstr ""
#: apps/note/forms.py:32
#: apps/note/forms.py:26
msgid "Maximal size: 2MB"
msgstr ""
#: apps/note/forms.py:77
msgid "Source and destination must be different."
msgstr ""
#: apps/note/models/notes.py:26
#: apps/note/models/notes.py:27
msgid "account balance"
msgstr ""
#: apps/note/models/notes.py:27
#: apps/note/models/notes.py:28
msgid "in centimes, money credited for this instance"
msgstr ""
#: apps/note/models/notes.py:31
#: apps/note/models/notes.py:32
msgid "last negative date"
msgstr ""
#: apps/note/models/notes.py:32
#: apps/note/models/notes.py:33
msgid "last time the balance was negative"
msgstr ""
#: apps/note/models/notes.py:37
#: apps/note/models/notes.py:38
msgid "active"
msgstr ""
#: apps/note/models/notes.py:40
#: apps/note/models/notes.py:41
msgid ""
"Designates whether this note should be treated as active. Unselect this "
"instead of deleting notes."
msgstr ""
#: apps/note/models/notes.py:44
#: apps/note/models/notes.py:45
msgid "display image"
msgstr ""
#: apps/note/models/notes.py:52 apps/note/models/transactions.py:102
#: apps/note/models/notes.py:53 apps/note/models/transactions.py:103
msgid "created at"
msgstr ""
#: apps/note/models/notes.py:58
#: apps/note/models/notes.py:59
msgid "notes"
msgstr ""
#: apps/note/models/notes.py:66
#: apps/note/models/notes.py:67
msgid "Note"
msgstr ""
#: apps/note/models/notes.py:76 apps/note/models/notes.py:100
#: apps/note/models/notes.py:77 apps/note/models/notes.py:101
msgid "This alias is already taken."
msgstr ""
#: apps/note/models/notes.py:120
#: apps/note/models/notes.py:121
msgid "one's note"
msgstr ""
#: apps/note/models/notes.py:121
#: apps/note/models/notes.py:122
msgid "users note"
msgstr ""
#: apps/note/models/notes.py:127
#: apps/note/models/notes.py:128
#, python-format
msgid "%(user)s's note"
msgstr ""
#: apps/note/models/notes.py:142
#: apps/note/models/notes.py:143
msgid "club note"
msgstr ""
#: apps/note/models/notes.py:143
#: apps/note/models/notes.py:144
msgid "clubs notes"
msgstr ""
#: apps/note/models/notes.py:149
#: apps/note/models/notes.py:150
#, python-format
msgid "Note of %(club)s club"
msgstr ""
#: apps/note/models/notes.py:169
#: apps/note/models/notes.py:170
msgid "special note"
msgstr ""
#: apps/note/models/notes.py:170
#: apps/note/models/notes.py:171
msgid "special notes"
msgstr ""
#: apps/note/models/notes.py:193
#: apps/note/models/notes.py:194
msgid "Invalid alias"
msgstr ""
#: apps/note/models/notes.py:209
#: apps/note/models/notes.py:210
msgid "alias"
msgstr ""
#: apps/note/models/notes.py:210 templates/member/profile_detail.html:37
#: apps/note/models/notes.py:211 templates/member/profile_detail.html:37
msgid "aliases"
msgstr ""
#: apps/note/models/notes.py:228
#: apps/note/models/notes.py:233
msgid "Alias is too long."
msgstr ""
#: apps/note/models/notes.py:233
#: apps/note/models/notes.py:238
msgid "An alias with a similar name already exists: {} "
msgstr ""
#: apps/note/models/notes.py:242
#: apps/note/models/notes.py:247
msgid "You can't delete your main alias."
msgstr ""
@ -386,7 +395,7 @@ msgstr ""
msgid "A template with this name already exist"
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"
msgstr ""
@ -394,74 +403,116 @@ msgstr ""
msgid "in centimes"
msgstr ""
#: apps/note/models/transactions.py:74
#: apps/note/models/transactions.py:75
msgid "transaction template"
msgstr ""
#: apps/note/models/transactions.py:75
#: apps/note/models/transactions.py:76
msgid "transaction templates"
msgstr ""
#: apps/note/models/transactions.py:106
#: apps/note/models/transactions.py:107
msgid "quantity"
msgstr ""
#: apps/note/models/transactions.py:111
msgid "reason"
#: apps/note/models/transactions.py:117 templates/note/transaction_form.html:15
msgid "Gift"
msgstr ""
#: apps/note/models/transactions.py:115
msgid "valid"
#: 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:120
msgid "transaction"
#: apps/note/models/transactions.py:119
msgid "Template"
msgstr ""
#: apps/note/models/transactions.py:121
msgid "transactions"
#: apps/note/models/transactions.py:120 templates/note/transaction_form.html:23
msgid "Credit"
msgstr ""
#: apps/note/models/transactions.py:184
#: 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:185
#: apps/note/models/transactions.py:129
msgid "reason"
msgstr ""
#: apps/note/models/transactions.py:133
msgid "valid"
msgstr ""
#: apps/note/models/transactions.py:138
msgid "transaction"
msgstr ""
#: apps/note/models/transactions.py:139
msgid "transactions"
msgstr ""
#: apps/note/models/transactions.py:207
msgid "first_name"
msgstr ""
#: apps/note/models/transactions.py:212
msgid "bank"
msgstr ""
#: apps/note/models/transactions.py:231
msgid "membership transactions"
msgstr ""
#: apps/note/views.py:29
msgid "Transfer money from your account to one or others"
#: apps/note/views.py:31
msgid "Transfer money"
msgstr ""
#: apps/note/views.py:138
msgid "Consommations"
#: apps/note/views.py:132 templates/base.html:78
msgid "Consumptions"
msgstr ""
#: note_kfet/settings/base.py:162
msgid "German"
msgstr ""
#: note_kfet/settings/base.py:163
msgid "English"
msgstr ""
#: note_kfet/settings/base.py:164
msgid "French"
msgstr ""
#: note_kfet/settings/base.py:215
#: note_kfet/settings/__init__.py:61
msgid ""
"The Central Authentication Service grants you access to most of our websites "
"by authenticating only once, so you don't need to type your credentials "
"again unless your session expires or you logout."
msgstr ""
#: note_kfet/settings/base.py:156
msgid "German"
msgstr ""
#: note_kfet/settings/base.py:157
msgid "English"
msgstr ""
#: note_kfet/settings/base.py:158
msgid "French"
msgstr ""
#: templates/base.html:13
msgid "The ENS Paris-Saclay BDE note."
msgstr ""
#: templates/cas_server/base.html:7 templates/cas_server/base.html:26
#: templates/base.html:81
msgid "Clubs"
msgstr ""
#: templates/base.html:84
msgid "Activities"
msgstr ""
#: templates/base.html:87
msgid "Buttons"
msgstr ""
#: templates/cas_server/base.html:7
msgid "Central Authentication Service"
msgstr ""
@ -511,6 +562,16 @@ msgstr ""
msgid "Connect to the service"
msgstr ""
#: templates/django_filters/rest_framework/crispy_form.html:4
#: templates/django_filters/rest_framework/form.html:2
msgid "Field filters"
msgstr ""
#: templates/django_filters/rest_framework/form.html:5
#: templates/member/club_form.html:10
msgid "Submit"
msgstr ""
#: templates/member/club_detail.html:10
msgid "Membership starts on"
msgstr ""
@ -531,6 +592,14 @@ msgstr ""
msgid "Transaction history"
msgstr ""
#: templates/member/club_form.html:6
msgid "Clubs list"
msgstr ""
#: templates/member/club_list.html:8
msgid "New club"
msgstr ""
#: templates/member/manage_auth_tokens.html:16
msgid "Token"
msgstr ""
@ -579,12 +648,87 @@ msgstr ""
msgid "Save Changes"
msgstr ""
#: templates/member/signup.html:5 templates/member/signup.html:8
#: templates/member/signup.html:14
msgid "Sign Up"
msgid "Sign up"
msgstr ""
#: templates/note/transaction_form.html:35
msgid "Transfer"
#: 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 ""
#: templates/note/transactiontemplate_form.html:6
msgid "Buttons list"
msgstr ""
#: templates/registration/logged_out.html:8
@ -596,7 +740,7 @@ msgid "Log in again"
msgstr ""
#: templates/registration/login.html:7 templates/registration/login.html:8
#: templates/registration/login.html:22
#: templates/registration/login.html:26
#: templates/registration/password_reset_complete.html:10
msgid "Log in"
msgstr ""
@ -608,7 +752,7 @@ msgid ""
"page. Would you like to login to a different account?"
msgstr ""
#: templates/registration/login.html:23
#: templates/registration/login.html:27
msgid "Forgotten your password or username?"
msgstr ""

View File

@ -3,7 +3,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-03-07 18:01+0100\n"
"POT-Creation-Date: 2020-03-16 11:53+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -18,9 +18,10 @@ msgid "activity"
msgstr "activité"
#: apps/activity/models.py:19 apps/activity/models.py:44
#: apps/member/models.py:60 apps/member/models.py:111
#: apps/note/models/notes.py:187 apps/note/models/transactions.py:24
#: apps/note/models/transactions.py:44 templates/member/profile_detail.html:15
#: 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/transactions.py:44 apps/note/models/transactions.py:202
#: templates/member/profile_detail.html:15
msgid "name"
msgstr "nom"
@ -44,8 +45,8 @@ msgstr "types d'activité"
msgid "description"
msgstr "description"
#: apps/activity/models.py:54 apps/note/models/notes.py:163
#: apps/note/models/transactions.py:62
#: apps/activity/models.py:54 apps/note/models/notes.py:164
#: apps/note/models/transactions.py:62 apps/note/models/transactions.py:115
msgid "type"
msgstr "type"
@ -81,19 +82,17 @@ msgstr "invités"
msgid "API"
msgstr ""
#: apps/logs/apps.py:10
#: apps/logs/apps.py:11
msgid "Logs"
msgstr ""
#: apps/logs/models.py:21 apps/note/models/notes.py:116
#: apps/logs/models.py:21 apps/note/models/notes.py:117
msgid "user"
msgstr "utilisateur"
#: apps/logs/models.py:27
#, fuzzy
#| msgid "address"
msgid "IP Address"
msgstr "adresse"
msgstr "Adresse IP"
#: apps/logs/models.py:35
msgid "model"
@ -108,22 +107,30 @@ msgid "previous data"
msgstr "Données précédentes"
#: apps/logs/models.py:52
#, fuzzy
#| msgid "end date"
msgid "new data"
msgstr "Nouvelles données"
#: apps/logs/models.py:59
#, fuzzy
#| msgid "section"
#: apps/logs/models.py:60
msgid "create"
msgstr "Créer"
#: apps/logs/models.py:61
msgid "edit"
msgstr "Modifier"
#: apps/logs/models.py:62
msgid "delete"
msgstr "Supprimer"
#: apps/logs/models.py:65
msgid "action"
msgstr "Action"
#: apps/logs/models.py:67
#: apps/logs/models.py:73
msgid "timestamp"
msgstr "Date"
#: apps/logs/models.py:71
#: apps/logs/models.py:77
msgid "Logs cannot be destroyed."
msgstr "Les logs ne peuvent pas être détruits."
@ -155,37 +162,37 @@ msgstr "payé"
msgid "user profile"
msgstr "profil utilisateur"
#: apps/member/models.py:65
#: apps/member/models.py:66
msgid "email"
msgstr "courriel"
#: apps/member/models.py:70
#: apps/member/models.py:71
msgid "membership fee"
msgstr "cotisation pour adhérer"
#: apps/member/models.py:74
#: apps/member/models.py:75
msgid "membership duration"
msgstr "durée de l'adhésion"
#: apps/member/models.py:75
#: apps/member/models.py:76
msgid "The longest time a membership can last (NULL = infinite)."
msgstr "La durée maximale d'une adhésion (NULL = infinie)."
#: apps/member/models.py:80
#: apps/member/models.py:81
msgid "membership start"
msgstr "début de l'adhésion"
#: apps/member/models.py:81
#: apps/member/models.py:82
msgid "How long after January 1st the members can renew their membership."
msgstr ""
"Combien de temps après le 1er Janvier les adhérents peuvent renouveler leur "
"adhésion."
#: apps/member/models.py:86
#: apps/member/models.py:87
msgid "membership end"
msgstr "fin de l'adhésion"
#: apps/member/models.py:87
#: apps/member/models.py:88
msgid ""
"How long the membership can last after January 1st of the next year after "
"members can renew their membership."
@ -193,39 +200,39 @@ msgstr ""
"Combien de temps l'adhésion peut durer après le 1er Janvier de l'année "
"suivante avant que les adhérents peuvent renouveler leur adhésion."
#: apps/member/models.py:93 apps/note/models/notes.py:138
#: apps/member/models.py:94 apps/note/models/notes.py:139
msgid "club"
msgstr "club"
#: apps/member/models.py:94
#: apps/member/models.py:95
msgid "clubs"
msgstr "clubs"
#: apps/member/models.py:117
#: apps/member/models.py:118
msgid "role"
msgstr "rôle"
#: apps/member/models.py:118
#: apps/member/models.py:119
msgid "roles"
msgstr "rôles"
#: apps/member/models.py:142
#: apps/member/models.py:143
msgid "membership starts on"
msgstr "l'adhésion commence le"
#: apps/member/models.py:145
#: apps/member/models.py:146
msgid "membership ends on"
msgstr "l'adhésion finie le"
#: apps/member/models.py:149
#: apps/member/models.py:150
msgid "fee"
msgstr "cotisation"
#: apps/member/models.py:153
#: apps/member/models.py:154
msgid "membership"
msgstr "adhésion"
#: apps/member/models.py:154
#: apps/member/models.py:155
msgid "memberships"
msgstr "adhésions"
@ -242,145 +249,137 @@ msgstr "Un alias avec un nom similaire existe déjà."
msgid "Account #%(id)s: %(username)s"
msgstr "Compte n°%(id)s : %(username)s"
#: apps/member/views.py:200
#: apps/member/views.py:202
msgid "Alias successfully deleted"
msgstr ""
msgstr "L'alias a bien été supprimé"
#: apps/note/admin.py:120 apps/note/models/transactions.py:93
#: apps/note/admin.py:120 apps/note/models/transactions.py:94
msgid "source"
msgstr "source"
#: 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"
msgstr "destination"
#: apps/note/apps.py:14 apps/note/models/notes.py:57
#: apps/note/apps.py:14 apps/note/models/notes.py:58
msgid "note"
msgstr "note"
#: apps/note/forms.py:26
#: apps/note/forms.py:20
msgid "New Alias"
msgstr ""
msgstr "Nouvel alias"
#: apps/note/forms.py:31
#, fuzzy
#| msgid "display image"
#: apps/note/forms.py:25
msgid "select an image"
msgstr "image affichée"
msgstr "Choisissez une image"
#: apps/note/forms.py:32
#: apps/note/forms.py:26
msgid "Maximal size: 2MB"
msgstr ""
msgstr "Taille maximale : 2 Mo"
#: apps/note/forms.py:77
msgid "Source and destination must be different."
msgstr "La source et la destination doivent être différentes."
#: apps/note/models/notes.py:26
#: apps/note/models/notes.py:27
msgid "account balance"
msgstr "solde du compte"
#: apps/note/models/notes.py:27
#: apps/note/models/notes.py:28
msgid "in centimes, money credited for this instance"
msgstr "en centimes, argent crédité pour cette instance"
#: apps/note/models/notes.py:31
#: apps/note/models/notes.py:32
msgid "last negative date"
msgstr "dernier date de négatif"
#: apps/note/models/notes.py:32
#: apps/note/models/notes.py:33
msgid "last time the balance was negative"
msgstr "dernier instant où la note était en négatif"
#: apps/note/models/notes.py:37
#: apps/note/models/notes.py:38
msgid "active"
msgstr "actif"
#: apps/note/models/notes.py:40
#: apps/note/models/notes.py:41
msgid ""
"Designates whether this note should be treated as active. Unselect this "
"instead of deleting notes."
msgstr ""
"Indique si la note est active. Désactiver cela plutôt que supprimer la note."
#: apps/note/models/notes.py:44
#: apps/note/models/notes.py:45
msgid "display image"
msgstr "image affichée"
#: apps/note/models/notes.py:52 apps/note/models/transactions.py:102
#: apps/note/models/notes.py:53 apps/note/models/transactions.py:103
msgid "created at"
msgstr "créée le"
#: apps/note/models/notes.py:58
#: apps/note/models/notes.py:59
msgid "notes"
msgstr "notes"
#: apps/note/models/notes.py:66
#: apps/note/models/notes.py:67
msgid "Note"
msgstr "Note"
#: apps/note/models/notes.py:76 apps/note/models/notes.py:100
#: apps/note/models/notes.py:77 apps/note/models/notes.py:101
msgid "This alias is already taken."
msgstr "Cet alias est déjà pris."
#: apps/note/models/notes.py:120
#: apps/note/models/notes.py:121
msgid "one's note"
msgstr "note d'un utilisateur"
#: apps/note/models/notes.py:121
#: apps/note/models/notes.py:122
msgid "users note"
msgstr "notes des utilisateurs"
#: apps/note/models/notes.py:127
#: apps/note/models/notes.py:128
#, python-format
msgid "%(user)s's note"
msgstr "Note de %(user)s"
#: apps/note/models/notes.py:142
#: apps/note/models/notes.py:143
msgid "club note"
msgstr "note d'un club"
#: apps/note/models/notes.py:143
#: apps/note/models/notes.py:144
msgid "clubs notes"
msgstr "notes des clubs"
#: apps/note/models/notes.py:149
#: apps/note/models/notes.py:150
#, python-format
msgid "Note of %(club)s club"
msgstr "Note du club %(club)s"
#: apps/note/models/notes.py:169
#: apps/note/models/notes.py:170
msgid "special note"
msgstr "note spéciale"
#: apps/note/models/notes.py:170
#: apps/note/models/notes.py:171
msgid "special notes"
msgstr "notes spéciales"
#: apps/note/models/notes.py:193
#: apps/note/models/notes.py:194
msgid "Invalid alias"
msgstr "Alias invalide"
#: apps/note/models/notes.py:209
#: apps/note/models/notes.py:210
msgid "alias"
msgstr "alias"
#: apps/note/models/notes.py:210 templates/member/profile_detail.html:37
#: apps/note/models/notes.py:211 templates/member/profile_detail.html:37
msgid "aliases"
msgstr "alias"
#: apps/note/models/notes.py:228
#: apps/note/models/notes.py:233
msgid "Alias is too long."
msgstr "L'alias est trop long."
#: apps/note/models/notes.py:233
#, fuzzy
#| msgid "An alias with a similar name already exists:"
#: apps/note/models/notes.py:238
msgid "An alias with a similar name already exists: {} "
msgstr "Un alias avec un nom similaire existe déjà."
msgstr "Un alias avec un nom similaire existe déjà : {}"
#: apps/note/models/notes.py:242
#: apps/note/models/notes.py:247
msgid "You can't delete your main alias."
msgstr "Vous ne pouvez pas supprimer votre alias principal."
@ -393,11 +392,10 @@ msgid "transaction categories"
msgstr "catégories de transaction"
#: apps/note/models/transactions.py:47
#, fuzzy
msgid "A template with this name already exist"
msgstr "Un modèle de transaction avec un nom similaire existe déjà."
#: 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"
msgstr "montant"
@ -405,74 +403,116 @@ msgstr "montant"
msgid "in centimes"
msgstr "en centimes"
#: apps/note/models/transactions.py:74
#: apps/note/models/transactions.py:75
msgid "transaction template"
msgstr "modèle de transaction"
#: apps/note/models/transactions.py:75
#: apps/note/models/transactions.py:76
msgid "transaction templates"
msgstr "modèles de transaction"
#: apps/note/models/transactions.py:106
#: apps/note/models/transactions.py:107
msgid "quantity"
msgstr "quantité"
#: apps/note/models/transactions.py:111
msgid "reason"
msgstr "raison"
#: apps/note/models/transactions.py:117 templates/note/transaction_form.html:15
msgid "Gift"
msgstr "Don"
#: apps/note/models/transactions.py:115
msgid "valid"
msgstr "valide"
#: 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 "Virement"
#: apps/note/models/transactions.py:120
msgid "transaction"
msgstr "transaction"
#: apps/note/models/transactions.py:119
msgid "Template"
msgstr "Bouton"
#: apps/note/models/transactions.py:121
msgid "transactions"
msgstr "transactions"
#: apps/note/models/transactions.py:120 templates/note/transaction_form.html:23
msgid "Credit"
msgstr "Crédit"
#: apps/note/models/transactions.py:184
#: apps/note/models/transactions.py:121 templates/note/transaction_form.html:27
msgid "Debit"
msgstr "Retrait"
#: apps/note/models/transactions.py:122 apps/note/models/transactions.py:230
msgid "membership transaction"
msgstr "transaction d'adhésion"
#: apps/note/models/transactions.py:185
#: apps/note/models/transactions.py:129
msgid "reason"
msgstr "raison"
#: apps/note/models/transactions.py:133
msgid "valid"
msgstr "valide"
#: apps/note/models/transactions.py:138
msgid "transaction"
msgstr "transaction"
#: apps/note/models/transactions.py:139
msgid "transactions"
msgstr "transactions"
#: apps/note/models/transactions.py:207
msgid "first_name"
msgstr "Prénom"
#: apps/note/models/transactions.py:212
msgid "bank"
msgstr "Banque"
#: apps/note/models/transactions.py:231
msgid "membership transactions"
msgstr "transactions d'adhésion"
#: apps/note/views.py:29
msgid "Transfer money from your account to one or others"
msgstr "Transfert d'argent de ton compte vers un ou plusieurs autres"
#: apps/note/views.py:31
msgid "Transfer money"
msgstr "Transferts d'argent"
#: apps/note/views.py:138
msgid "Consommations"
msgstr "transactions"
#: apps/note/views.py:132 templates/base.html:78
msgid "Consumptions"
msgstr "Consommations"
#: note_kfet/settings/base.py:162
msgid "German"
msgstr ""
#: note_kfet/settings/base.py:163
msgid "English"
msgstr ""
#: note_kfet/settings/base.py:164
msgid "French"
msgstr ""
#: note_kfet/settings/base.py:215
#: note_kfet/settings/__init__.py:61
msgid ""
"The Central Authentication Service grants you access to most of our websites "
"by authenticating only once, so you don't need to type your credentials "
"again unless your session expires or you logout."
msgstr ""
#: note_kfet/settings/base.py:156
msgid "German"
msgstr ""
#: note_kfet/settings/base.py:157
msgid "English"
msgstr ""
#: note_kfet/settings/base.py:158
msgid "French"
msgstr ""
#: templates/base.html:13
msgid "The ENS Paris-Saclay BDE note."
msgstr "La note du BDE de l'ENS Paris-Saclay."
#: templates/cas_server/base.html:7 templates/cas_server/base.html:26
#: templates/base.html:81
msgid "Clubs"
msgstr "Clubs"
#: templates/base.html:84
msgid "Activities"
msgstr "Activités"
#: templates/base.html:87
msgid "Buttons"
msgstr "Boutons"
#: templates/cas_server/base.html:7
msgid "Central Authentication Service"
msgstr ""
@ -510,11 +550,11 @@ msgstr ""
#: templates/cas_server/login.html:11
msgid ""
"If you don't have any Note Kfet account, please follow <a href='/accounts"
"/signup'>this link to sign up</a>."
"If you don't have any Note Kfet account, please follow <a href='/accounts/"
"signup'>this link to sign up</a>."
msgstr ""
"Si vous n'avez pas de compte Note Kfet, veuillez suivre <a href='/accounts"
"/signup'>ce lien pour vous inscrire</a>."
"Si vous n'avez pas de compte Note Kfet, veuillez suivre <a href='/accounts/"
"signup'>ce lien pour vous inscrire</a>."
#: templates/cas_server/login.html:17
msgid "Login"
@ -524,6 +564,16 @@ msgstr ""
msgid "Connect to the service"
msgstr ""
#: templates/django_filters/rest_framework/crispy_form.html:4
#: templates/django_filters/rest_framework/form.html:2
msgid "Field filters"
msgstr ""
#: templates/django_filters/rest_framework/form.html:5
#: templates/member/club_form.html:10
msgid "Submit"
msgstr "Envoyer"
#: templates/member/club_detail.html:10
msgid "Membership starts on"
msgstr "L'adhésion commence le"
@ -544,6 +594,14 @@ msgstr "solde du compte"
msgid "Transaction history"
msgstr "Historique des transactions"
#: templates/member/club_form.html:6
msgid "Clubs list"
msgstr "Liste des clubs"
#: templates/member/club_list.html:8
msgid "New club"
msgstr "Nouveau club"
#: templates/member/manage_auth_tokens.html:16
msgid "Token"
msgstr "Jeton"
@ -557,10 +615,8 @@ msgid "Regenerate token"
msgstr "Regénérer le jeton"
#: templates/member/profile_alias.html:10
#, fuzzy
#| msgid "alias"
msgid "Add alias"
msgstr "alias"
msgstr "Ajouter un alias"
#: templates/member/profile_detail.html:15
msgid "first name"
@ -583,10 +639,8 @@ msgid "Manage auth token"
msgstr "Gérer les jetons d'authentification"
#: templates/member/profile_detail.html:49
#, fuzzy
#| msgid "Update Profile"
msgid "View Profile"
msgstr "Modifier le profil"
msgstr "Voir le profil"
#: templates/member/profile_detail.html:62
msgid "View my memberships"
@ -596,13 +650,88 @@ msgstr "Voir mes adhésions"
msgid "Save Changes"
msgstr "Sauvegarder les changements"
#: templates/member/signup.html:5 templates/member/signup.html:8
#: templates/member/signup.html:14
msgid "Sign Up"
msgstr ""
msgid "Sign up"
msgstr "Inscription"
#: templates/note/transaction_form.html:35
msgid "Transfer"
msgstr "Virement"
#: templates/note/conso_form.html:28 templates/note/transaction_form.html:38
msgid "Select emitters"
msgstr "Sélection des émetteurs"
#: templates/note/conso_form.html:45
msgid "Select consumptions"
msgstr "Consommations"
#: templates/note/conso_form.html:51
msgid "Consume!"
msgstr "Consommer !"
#: templates/note/conso_form.html:64
msgid "Most used buttons"
msgstr "Boutons les plus utilisés"
#: templates/note/conso_form.html:121
msgid "Edit"
msgstr "Éditer"
#: templates/note/conso_form.html:126
msgid "Single consumptions"
msgstr "Consos simples"
#: templates/note/conso_form.html:130
msgid "Double consumptions"
msgstr "Consos doubles"
#: templates/note/conso_form.html:141
msgid "Recent transactions history"
msgstr "Historique des transactions récentes"
#: templates/note/transaction_form.html:55
msgid "External payment"
msgstr "Paiement extérieur"
#: templates/note/transaction_form.html:63
msgid "Transfer type"
msgstr "Type de transfert"
#: templates/note/transaction_form.html:73
msgid "Name"
msgstr "Nom"
#: templates/note/transaction_form.html:79
msgid "First name"
msgstr "Prénom"
#: templates/note/transaction_form.html:85
msgid "Bank"
msgstr "Banque"
#: templates/note/transaction_form.html:97
#: templates/note/transaction_form.html:179
#: templates/note/transaction_form.html:186
msgid "Select receivers"
msgstr "Sélection des destinataires"
#: templates/note/transaction_form.html:114
msgid "Amount"
msgstr "Montant"
#: templates/note/transaction_form.html:119
msgid "Reason"
msgstr "Raison"
#: templates/note/transaction_form.html:193
msgid "Credit note"
msgstr "Note à créditer"
#: templates/note/transaction_form.html:200
msgid "Debit note"
msgstr "Note à débiter"
#: templates/note/transactiontemplate_form.html:6
msgid "Buttons list"
msgstr "Liste des boutons"
#: templates/registration/logged_out.html:8
msgid "Thanks for spending some quality time with the Web site today."
@ -613,7 +742,7 @@ msgid "Log in again"
msgstr ""
#: templates/registration/login.html:7 templates/registration/login.html:8
#: templates/registration/login.html:22
#: templates/registration/login.html:26
#: templates/registration/password_reset_complete.html:10
msgid "Log in"
msgstr ""
@ -625,7 +754,7 @@ msgid ""
"page. Would you like to login to a different account?"
msgstr ""
#: templates/registration/login.html:23
#: templates/registration/login.html:27
msgid "Forgotten your password or username?"
msgstr ""

View File

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

View File

@ -1,4 +1,9 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.utils.translation import gettext_lazy as _
import re
from .base import *
@ -30,28 +35,28 @@ read_env()
app_stage = os.environ.get('DJANGO_APP_STAGE', 'dev')
if app_stage == 'prod':
from .production import *
DATABASES["default"]["PASSWORD"] = os.environ.get('DJANGO_DB_PASSWORD', 'CHANGE_ME_IN_ENV_SETTINGS')
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'CHANGE_ME_IN_ENV_SETTINGS')
ALLOWED_HOSTS = [os.environ.get('ALLOWED_HOSTS', 'localhost')]
else:
from .development import *
try:
#in secrets.py defines everything you want
from .secrets import *
INSTALLED_APPS += OPTIONAL_APPS
except ImportError:
pass
if "cas" in INSTALLED_APPS:
MIDDLEWARE += ['cas.middleware.CASMiddleware']
# CAS Settings
CAS_SERVER_URL = "https://" + os.getenv("NOTE_URL", "note.example.com") + "/cas/"
CAS_AUTO_CREATE_USER = False
CAS_LOGO_URL = "/static/img/Saperlistpopette.png"
CAS_FAVICON_URL = "/static/favicon/favicon-32x32.png"
CAS_SHOW_SERVICE_MESSAGES = True
CAS_SHOW_POWERED = False
CAS_REDIRECT_TO_LOGIN_AFTER_LOGOUT = False
CAS_PROVIDE_URL_TO_LOGOUT = True
CAS_INFO_MESSAGES = {
"cas_explained": {
"message": _(
@ -69,6 +74,10 @@ if "cas" in INSTALLED_APPS:
]
AUTHENTICATION_BACKENDS += ('cas.backends.CASBackend',)
if "logs" in INSTALLED_APPS:
MIDDLEWARE += ('note_kfet.middlewares.SessionMiddleware',)
if "debug_toolbar" in INSTALLED_APPS:
MIDDLEWARE.insert(1,"debug_toolbar.middleware.DebugToolbarMiddleware")
INTERNAL_IPS = [ '127.0.0.1']
MIDDLEWARE.insert(1, "debug_toolbar.middleware.DebugToolbarMiddleware")
INTERNAL_IPS = ['127.0.0.1']

View File

@ -59,6 +59,7 @@ INSTALLED_APPS = [
'activity',
'member',
'note',
'permission',
'api',
'logs',
]
@ -124,22 +125,21 @@ PASSWORD_HASHERS = [
'member.hashers.CustomNK15Hasher',
]
# Django Guardian object permissions
AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend', # this is default
'permission.backends.PermissionBackend', # Custom role-based permission system
)
REST_FRAMEWORK = {
# Use Django's standard `django.contrib.auth` permissions,
# or allow read-only access for unauthenticated users.
'DEFAULT_PERMISSION_CLASSES': [
# TODO Maybe replace it with our custom permissions system
'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly'
# Control API access with our role-based permission system
'permission.permissions.StrongDjangoObjectPermissions',
],
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.TokenAuthentication',
]
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 20,
}
# Internationalization

View File

@ -17,12 +17,24 @@ import os
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
from . import *
DATABASES = {
if os.getenv("DJANGO_DEV_STORE_METHOD", "sqllite") == "postgresql":
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': os.environ.get('DJANGO_DB_NAME', 'note_db'),
'USER': os.environ.get('DJANGO_DB_USER', 'note'),
'PASSWORD': os.environ.get('DJANGO_DB_PASSWORD', 'CHANGE_ME_IN_ENV_SETTINGS'),
'HOST': os.environ.get('DJANGO_DB_HOST', 'localhost'),
'PORT': os.environ.get('DJANGO_DB_PORT', ''), # Use default port
}
}
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}
}
# Break it, fix it!
DEBUG = True
@ -39,7 +51,7 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# EMAIL_HOST_USER = 'change_me'
# EMAIL_HOST_PASSWORD = 'change_me'
SERVER_EMAIL = 'no-reply@example.org'
SERVER_EMAIL = 'no-reply@' + os.getenv("DOMAIN", "example.com")
# Security settings
SECURE_CONTENT_TYPE_NOSNIFF = False

View File

@ -1,6 +1,8 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import os
########################
# Production Settings #
########################
@ -14,11 +16,11 @@
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': 'note_db',
'USER': 'note',
'PASSWORD': 'update_in_env_variable',
'HOST': '127.0.0.1',
'PORT': '',
'NAME': os.environ.get('DJANGO_DB_NAME', 'note_db'),
'USER': os.environ.get('DJANGO_DB_USER', 'note'),
'PASSWORD': os.environ.get('DJANGO_DB_PASSWORD', 'CHANGE_ME_IN_ENV_SETTINGS'),
'HOST': os.environ.get('DJANGO_DB_HOST', 'localhost'),
'PORT': os.environ.get('DJANGO_DB_PORT', ''), # Use default port
}
}
@ -26,7 +28,9 @@ DATABASES = {
DEBUG = True
# Mandatory !
ALLOWED_HOSTS = []
ALLOWED_HOSTS = [os.environ.get('NOTE_URL', 'localhost')]
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'CHANGE_ME_IN_ENV_SETTINGS')
# Emails
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
@ -37,7 +41,7 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# EMAIL_HOST_USER = 'change_me'
# EMAIL_HOST_PASSWORD = 'change_me'
SERVER_EMAIL = 'no-reply@example.org'
SERVER_EMAIL = 'no-reply@' + os.getenv("DOMAIN", "example.com")
# Security settings
SECURE_CONTENT_TYPE_NOSNIFF = False
@ -49,4 +53,4 @@ X_FRAME_OPTIONS = 'DENY'
SESSION_COOKIE_AGE = 60 * 60 * 3
# CAS Client settings
CAS_SERVER_URL = "https://note.crans.org/cas/"
CAS_SERVER_URL = "https://" + os.getenv("NOTE_URL", "note.example.com") + "/cas/"

View File

@ -7,6 +7,8 @@ from django.contrib import admin
from django.urls import path, include
from django.views.generic import RedirectView
from member.views import CustomLoginView
urlpatterns = [
# Dev so redirect to something random
path('', RedirectView.as_view(pattern_name='note:transfer'), name='index'),
@ -16,11 +18,11 @@ urlpatterns = [
# Include Django Contrib and Core routers
path('i18n/', include('django.conf.urls.i18n')),
path('accounts/', include('member.urls')),
path('accounts/', include('django.contrib.auth.urls')),
path('admin/doc/', include('django.contrib.admindocs.urls')),
path('admin/', admin.site.urls),
path('logs/', include('logs.urls')),
path('accounts/', include('member.urls')),
path('accounts/login/', CustomLoginView.as_view()),
path('accounts/', include('django.contrib.auth.urls')),
path('api/', include('api.urls')),
]
@ -37,8 +39,8 @@ if "cas" in settings.INSTALLED_APPS:
from cas import views as cas_views
urlpatterns += [
# Include CAS Client routers
path('accounts/login/', cas_views.login, name='login'),
path('accounts/logout/', cas_views.logout, name='logout'),
path('accounts/login/cas/', cas_views.login, name='cas_login'),
path('accounts/logout/cas/', cas_views.logout, name='cas_logout'),
]
if "debug_toolbar" in settings.INSTALLED_APPS:

View File

@ -1,3 +0,0 @@
djangorestframework==3.9.0
django-rest-polymorphic==0.1.8

View File

@ -19,4 +19,6 @@ requests==2.22.0
requests-oauthlib==1.2.0
six==1.12.0
sqlparse==0.3.0
djangorestframework==3.9.0
django-rest-polymorphic==0.1.8
urllib3==1.25.3

297
static/js/base.js Normal file
View 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
View 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
View 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();
});
}
});

View File

@ -1,4 +1,4 @@
{% load static i18n pretty_money static %}
{% load static i18n pretty_money static getenv perms %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
@ -46,12 +46,20 @@ SPDX-License-Identifier: GPL-3.0-or-later
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/turbolinks/5.2.0/turbolinks.js"
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 #}
{% if form.media %}
{{ form.media }}
{% endif %}
<style>
.validate:hover {
cursor: pointer;
text-decoration: underline;
}
</style>
{% block extracss %}{% endblock %}
</head>
<body class="d-flex w-100 h-100 flex-column">
@ -66,18 +74,26 @@ SPDX-License-Identifier: GPL-3.0-or-later
</button>
<div class="collapse navbar-collapse" id="navbarNavDropdown">
<ul class="navbar-nav">
{% if "note.transactiontemplate"|not_empty_model_list %}
<li class="nav-item active">
<a class="nav-link" href="{% url 'note:consos' %}"><i class="fa fa-coffee"></i> {% trans 'Consumptions' %}</a>
</li>
{% endif %}
{% if "member.club"|not_empty_model_list %}
<li class="nav-item active">
<a class="nav-link" href="{% url 'member:club_list' %}"><i class="fa fa-users"></i> {% trans 'Clubs' %}</a>
</li>
{% endif %}
{% if "activity.activity"|not_empty_model_list %}
<li class="nav-item active">
<a class="nav-link" href="#"><i class="fa fa-calendar"></i> {% trans 'Activities' %}</a>
</li>
{% endif %}
{% if "note.transactiontemplate"|not_empty_model_change_list %}
<li class="nav-item active">
<a class="nav-link" href="{% url 'note:template_list' %}"><i class="fa fa-coffee"></i> {% trans 'Button' %}</a>
<a class="nav-link" href="{% url 'note:template_list' %}"><i class="fa fa-coffee"></i> {% trans 'Buttons' %}</a>
</li>
{% endif %}
<li class="nav-item active">
<a class="nav-link" href="{% url 'note:transfer' %}"><i class="fa fa-exchange"></i>{% trans 'Transfer' %} </a>
</li>
@ -86,7 +102,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% if user.is_authenticated %}
<li class="dropdown">
<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>
<div class="dropdown-menu dropdown-menu-right"
aria-labelledby="navbarDropdownMenuLink">
@ -115,6 +132,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
</nav>
<div class="container-fluid my-3" style="max-width: 1600px;">
{% block contenttitle %}<h1>{{ title }}</h1>{% endblock %}
<div id="messages"></div>
{% block content %}
<p>Default content...</p>
{% endblock content %}
@ -128,7 +146,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
class="form-inline">
<span class="text-muted mr-1">
NoteKfet2020 &mdash;
<a href="mailto:tresorie.bde@lists.crans.org"
<a href="mailto:{{ "CONTACT_EMAIL" | getenv }}"
class="text-muted">Nous contacter</a> &mdash;
</span>
{% csrf_token %}
@ -158,6 +176,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div>
</footer>
<script>
CSRF_TOKEN = "{{ csrf_token }}";
</script>
{% block extrajavascript %}
{% endblock extrajavascript %}
</body>

View File

@ -0,0 +1,5 @@
{% load crispy_forms_tags %}
{% load i18n %}
<h2>{% trans "Field filters" %}</h2>
{% crispy filter.form %}

View File

@ -0,0 +1,6 @@
{% load i18n %}
<h2>{% trans "Field filters" %}</h2>
<form class="form" action="" method="get">
{{ filter.form.as_p }}
<button type="submit" class="btn btn-primary">{% trans "Submit" %}</button>
</form>

View File

@ -0,0 +1 @@
{% for widget in widget.subwidgets %}{% include widget.template_name %}{% if forloop.first %}-{% endif %}{% endfor %}

View File

@ -1,11 +1,12 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block content %}
<p><a class="btn btn-default" href="{% url 'note:template_list' %}">Template Listing</a></p>
<p><a class="btn btn-default" href="{% url 'note:template_list' %}">{% trans "Clubs list" %}</a></p>
<form method="post">
{% csrf_token %}
{{form|crispy}}
<button class="btn btn-primary" type="submit">Submit</button>
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
</form>
{% endblock %}

View File

@ -1,10 +1,11 @@
{% extends "base.html" %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block content %}
{% render_table table %}
<a class="btn btn-primary" href="{% url 'member:club_create' %}">New Club</a>
<a class="btn btn-primary" href="{% url 'member:club_create' %}">{% trans "New club" %}</a>
{% endblock %}
{% block extrajavascript %}

View File

@ -10,7 +10,7 @@
<img src="{{ object.note.display_image.url }}" class="img-thumbnail mt-2" >
</a>
</div>
<div class="card-body">
<div class="card-body" id="profile_infos">
<dl class="row">
<dt class="col-xl-6">{% trans 'name'|capfirst %}, {% trans 'first name' %}</dt>
<dd class="col-xl-6">{{ object.last_name }} {{ object.first_name }}</dd>
@ -76,11 +76,22 @@
</a>
</div>
<div id="historyListCollapse" class="collapse" style="overflow:auto hidden" aria-labelledby="historyListHeading" data-parent="#accordionProfile">
<div id="history_list">
{% render_table history_list %}
</div>
</div>
</div>
</div>
{% endblock %}
</div>
</div>
{% 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 %}

View File

@ -2,16 +2,16 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% load i18n %}
{% block title %}Sign Up{% endblock %}
{% block title %}{% trans "Sign up" %}{% endblock %}
{% block content %}
<h2>Sign up</h2>
<h2>{% trans "Sign up" %}</h2>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
{{ profile_form|crispy }}
<button class="btn btn-success" type="submit">
{% trans "Sign Up" %}
{% trans "Sign up" %}
</button>
</form>
{% endblock %}

View File

@ -1,52 +1,93 @@
{% extends "base.html" %}
{% load i18n static pretty_money %}
{% load i18n static pretty_money django_tables2 %}
{# Remove page title #}
{% block contenttitle %}{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-sm-5 col-md-4" id="infos_div">
<div class="row">
{# User details column #}
<div class="col-xl-5" 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>
{# User selection column #}
<div class="col-xl-7" id="user_select_div">
<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="note_list">
</ul>
<div class="card-body">
<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 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>
{# Buttons column #}
<div class="col-sm-7 col-md-8" id="buttons_div">
{# Show last used buttons #}
<div class="card shadow mb-4">
<div class="card-header">
<p class="card-text font-weight-bold">
{% trans "Most used buttons" %}
</p>
</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>
{# Regroup buttons under categories #}
{% regroup transaction_templates by category as categories %}
<form method="post" onsubmit="window.onbeforeunload=null">
{% csrf_token %}
<div class="row">
<div class="col-sm-5 mb-4">
{% if form.non_field_errors %}
<p class="errornote">
{% for error in form.non_field_errors %}
{{ error }}
{% endfor %}
</p>
{% endif %}
{% for field in form %}
<div class="form-row{% if field.errors %} errors{% endif %}">
{{ field.errors }}
<div>
{{ field.label_tag }}
{% if field.is_readonly %}
<div class="readonly">{{ field.contents }}</div>
{% else %}
{{ field }}
{% endif %}
{% if field.field.help_text %}
<div class="help">{{ field.field.help_text|safe }}</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<div class="col-sm-7">
<div class="card text-center shadow">
<div class="card border-primary text-center shadow mb-4">
{# Tabs for button categories #}
<div class="card-header">
<ul class="nav nav-tabs nav-fill card-header-tabs">
{% for category in categories %}
<li class="nav-item">
<a class="nav-link" data-toggle="tab" href="#{{ category.grouper|slugify }}">
<a class="nav-link font-weight-bold" data-toggle="tab" href="#{{ category.grouper|slugify }}">
{{ category.grouper }}
</a>
</li>
@ -61,37 +102,70 @@
<div class="tab-pane" id="{{ category.grouper|slugify }}">
<div class="d-inline-flex flex-wrap justify-content-center">
{% for button in category.list %}
{% if button.display %}
<button class="btn btn-outline-dark rounded-0 flex-fill"
name="button" value="{{ button.name }}">
id="button{{ button.id }}" name="button" value="{{ button.name }}">
{{ button.name }} ({{ button.amount | pretty_money }})
</button>
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{# Mode switch #}
<div class="card-footer border-primary">
<a class="btn btn-sm btn-secondary float-left" href="{% url 'note:template_list' %}">
<i class="fa fa-edit"></i> {% trans "Edit" %}
</a>
<div class="btn-group btn-group-toggle float-right" data-toggle="buttons">
<label for="single_conso" class="btn btn-sm btn-outline-primary active">
<input type="radio" name="conso_type" id="single_conso" checked>
{% trans "Single consumptions" %}
</label>
<label for="double_conso" class="btn btn-sm btn-outline-primary">
<input type="radio" name="conso_type" id="double_conso">
{% trans "Double consumptions" %}
</label>
</div>
</div>
</div>
</form>
</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 type="text/javascript" src="/static/js/consos.js"></script>
<script type="text/javascript">
$(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");
}
{% for button in most_used %}
{% if button.display %}
$("#most_used_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 %}
// When selecting a category, change URL
$(document.body).on("click", "a[data-toggle='tab']", function(event) {
location.hash = this.getAttribute("href");
});
{% for button in transaction_templates %}
{% if button.display %}
$("#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>
{% endblock %}

View File

@ -3,35 +3,192 @@
SPDX-License-Identifier: GPL-2.0-or-later
{% endcomment %}
{% load i18n static %}
{% load i18n static django_tables2 perms %}
{% block content %}
<form method="post" onsubmit="window.onbeforeunload=null">{% csrf_token %}
{% if form.non_field_errors %}
<p class="errornote">
{% for error in form.non_field_errors %}
{{ error }}
{% endfor %}
<div class="row">
<div class="col-xl-12">
<div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0" data-toggle="buttons">
<label for="type_gift" class="btn btn-sm btn-outline-primary active">
<input type="radio" name="transaction_type" id="type_gift" checked>
{% trans "Gift" %}
</label>
<label for="type_transfer" class="btn btn-sm btn-outline-primary">
<input type="radio" name="transaction_type" id="type_transfer">
{% trans "Transfer" %}
</label>
{% if "note.notespecial"|not_empty_model_list %}
<label for="type_credit" class="btn btn-sm btn-outline-primary">
<input type="radio" name="transaction_type" id="type_credit">
{% trans "Credit" %}
</label>
<label type="type_debit" class="btn btn-sm btn-outline-primary">
<input type="radio" name="transaction_type" id="type_debit">
{% trans "Debit" %}
</label>
{% endif %}
</div>
</div>
</div>
<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>
{% endif %}
<fieldset class="module aligned">
{% for field in form %}
<div class="form-row{% if field.errors %} errors{% endif %}">
{{ field.errors }}
<div>
{{ field.label_tag }}
{% if field.is_readonly %}
<div class="readonly">{{ field.contents }}</div>
{% else %}
{{ field }}
{% endif %}
{% if field.field.help_text %}
<div class="help">{{ field.field.help_text|safe }}</div>
{% endif %}
</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 %}
</fieldset>
<input type="submit" value="{% trans 'Transfer' %}">
</form>
</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>
{% endif %}
<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 %}

View File

@ -1,8 +1,9 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block content %}
<p><a class="btn btn-default" href="{% url 'note:template_list' %}">Template Listing</a></p>
<p><a class="btn btn-default" href="{% url 'note:template_list' %}">{% trans "Buttons list" %}</a></p>
<form method="post">
{% csrf_token %}
{{form|crispy}}

View File

@ -16,7 +16,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
{% endblocktrans %}
</p>
{% endif %}
{%url 'cas_login' as cas_url %}
{% if cas_url %}
<div class="alert alert-info">
{% trans "You can also register via the central authentification server " %}
<a href="{{ cas_url }}"> {% trans "using this link "%}</a>
</div>
{%endif%}
<form action="{{ app_path }}" method="post" id="login-form">{% csrf_token %}
{{ form | crispy }}
<input type="submit" value="{% trans 'Log in' %}" class="btn btn-primary">

View File

@ -10,7 +10,6 @@ setenv =
PYTHONWARNINGS = all
deps =
-r{toxinidir}/requirements/base.txt
-r{toxinidir}/requirements/api.txt
-r{toxinidir}/requirements/cas.txt
-r{toxinidir}/requirements/production.txt
coverage
@ -22,7 +21,6 @@ commands =
[testenv:linters]
deps =
-r{toxinidir}/requirements/base.txt
-r{toxinidir}/requirements/api.txt
-r{toxinidir}/requirements/cas.txt
-r{toxinidir}/requirements/production.txt
flake8
@ -32,7 +30,7 @@ deps =
pep8-naming
pyflakes
commands =
flake8 apps/activity apps/api apps/member apps/note
flake8 apps/activity apps/api apps/logs apps/member apps/note
[flake8]
# Ignore too many errors, should be reduced in the future