From 67d1d9f7b72f11a58e947909ccc198284afe2c43 Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Wed, 18 Sep 2019 14:26:42 +0200 Subject: [PATCH 001/119] Added permission app --- apps/member/backends.py | 33 +++++++++++ apps/member/models.py | 22 +++++++ apps/permission/__init__.py | 0 apps/permission/admin.py | 3 + apps/permission/apps.py | 5 ++ apps/permission/models.py | 112 ++++++++++++++++++++++++++++++++++++ apps/permission/tests.py | 3 + apps/permission/views.py | 3 + note_kfet/settings.py | 1 + 9 files changed, 182 insertions(+) create mode 100644 apps/member/backends.py create mode 100644 apps/permission/__init__.py create mode 100644 apps/permission/admin.py create mode 100644 apps/permission/apps.py create mode 100644 apps/permission/models.py create mode 100644 apps/permission/tests.py create mode 100644 apps/permission/views.py diff --git a/apps/member/backends.py b/apps/member/backends.py new file mode 100644 index 00000000..0b2edad8 --- /dev/null +++ b/apps/member/backends.py @@ -0,0 +1,33 @@ +from django.contribs.contenttype.models import ContentType +from member.models import Club, Membership, RolePermissions + + +class PermissionBackend(object): + supports_object_permissions = True + supports_anonymous_user = False + supports_inactive_user = False + + def authenticate(self, username, password): + return None + + def permissions(self, user, obj): + for membership in user.memberships.all(): + if not membership.valid() or membership.role is None: + continue + for permission in RolePermissions.objects.get(role=membership.role).permissions.objects.all(): + permission = permission.about(user=user, club=membership.club) + yield permission + + def has_perm(self, user_obj, perm, obj=None): + if obj is None: + return False + perm = perm.split('_') + perm_type = perm[1] + perm_field = perm[2] if len(perm) == 3 else None + return any(permission.applies(obj, perm_type, perm_field) for obj in self.permissions(user_obj, obj)) + + def get_all_permissions(self, user_obj, obj=None): + if obj is None: + return [] + else: + return list(self.permissions(user_obj, obj)) diff --git a/apps/member/models.py b/apps/member/models.py index 70f8ccf7..7eacdc60 100644 --- a/apps/member/models.py +++ b/apps/member/models.py @@ -2,6 +2,8 @@ # Copyright (C) 2018-2019 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.db.models.signals import post_save @@ -9,6 +11,7 @@ from django.dispatch import receiver from django.utils.translation import gettext_lazy as _ from django.urls import reverse + class Profile(models.Model): """ An user profile @@ -51,6 +54,7 @@ class Profile(models.Model): def get_absolute_url(self): return reverse('user_detail',args=(self.pk,)) + class Club(models.Model): """ A student club @@ -141,11 +145,29 @@ class Membership(models.Model): verbose_name=_('fee'), ) + def valid(self): + return self.date_start <= datetime.datetime.now() < self.date_end + class Meta: verbose_name = _('membership') verbose_name_plural = _('memberships') +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.Permission' + ) + + # @receiver(post_save, sender=settings.AUTH_USER_MODEL) # def save_user_profile(instance, created, **_kwargs): # """ diff --git a/apps/permission/__init__.py b/apps/permission/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/permission/admin.py b/apps/permission/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/apps/permission/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/permission/apps.py b/apps/permission/apps.py new file mode 100644 index 00000000..0f46ef08 --- /dev/null +++ b/apps/permission/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class PermissionConfig(AppConfig): + name = 'permission' diff --git a/apps/permission/models.py b/apps/permission/models.py new file mode 100644 index 00000000..b7cc8845 --- /dev/null +++ b/apps/permission/models.py @@ -0,0 +1,112 @@ +import json + +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import Q +from django.utils.translation import gettext_lazy as _ + + +class InstancedPermission: + + def __init__(self, model, permission, type, field): + self.model = model + self.permission = permission + self.type = type + self.field = field + + def applies(self, obj, permission_type, field_name=None): + if ContentType.objects.get_for_model(obj) != self.model: + # The permission does not apply to the object + return False + if self.permission is None: + if permission_type == self.type: + if field_name is not None: + return field_name == self.field + else: + return True + else: + return False + elif isinstance(self.permission, dict): + for field in self.permission: + value = getattr(obj, field) + if isinstance(value, models.Model): + value = value.pk + if value != self.permission[field]: + return False + elif isinstance(self.permission, type(obj.pk)): + if obj.pk != self.permission: + return False + if permission_type == self.type: + if field_name: + return field_name == self.field + else: + return True + return False + + def __repr__(self): + if self.field: + return _("Can {type} {model}.{field} in {permission}").format(type=self.type, model=self.model, field=self.field, permission=self.permission) + else: + return _("Can {type} {model} in {permission}").format(type=self.type, model=self.model, permission=self.permission) + + +class Permission(models.Model): + + PERMISSION_TYPES = [ + ('C', 'add'), + ('R', 'view'), + ('U', 'change'), + ('D', 'delete') + ] + + model = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name='+') + + permission = models.TextField() + + type = models.CharField(max_length=15, choices=PERMISSION_TYPES) + + field = models.CharField(max_length=255, blank=True) + + class Meta: + unique_together = ('model', 'permission', 'type', 'field') + + def clean(self): + if self.field and self.type not in {'R', 'U'}: + raise ValidationError(_("Specifying field applies only to view and change permission types.")) + + def save(self): + self.full_clean() + super().save() + + def _about(_self, _permission, **kwargs): + if _permission[0] == 'all': + return None + elif _permission[0] == 'pk': + if _permission[1] in kwargs: + return kwargs[_permission[1]].pk + else: + return None + elif _permission[0] == 'filter': + return {field: _self._about(_permission[1][field], **kwargs) for field in _permission[1]} + else: + return _permission + + def about(self, **kwargs): + permission = json.loads(self.permission) + permission = self._about(permission, **kwargs) + return InstancedPermission(self.model, permission, self.type, self.field) + + def __str__(self): + if self.field: + return _("Can {type} {model}.{field} in {permission}").format(type=self.type, model=self.model, field=self.field, permission=self.permission) + else: + return _("Can {type} {model} in {permission}").format(type=self.type, model=self.model, permission=self.permission) + + +class UserPermission(models.Model): + + user = models.ForeignKey('auth.User', on_delete=models.CASCADE) + + permission = models.ForeignKey(Permission, on_delete=models.CASCADE) + diff --git a/apps/permission/tests.py b/apps/permission/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/apps/permission/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/permission/views.py b/apps/permission/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/apps/permission/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/note_kfet/settings.py b/note_kfet/settings.py index cfe09f7b..3cd3b717 100644 --- a/note_kfet/settings.py +++ b/note_kfet/settings.py @@ -56,6 +56,7 @@ INSTALLED_APPS = [ 'activity', 'member', 'note', + 'permission' ] MIDDLEWARE = [ From 2a4ab0975353a3c9bb4ba82149c6768cff3c06c1 Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Wed, 18 Sep 2019 16:39:37 +0200 Subject: [PATCH 002/119] [permission] Use full names for permission types --- apps/permission/models.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/permission/models.py b/apps/permission/models.py index b7cc8845..73000cbd 100644 --- a/apps/permission/models.py +++ b/apps/permission/models.py @@ -54,10 +54,10 @@ class InstancedPermission: class Permission(models.Model): PERMISSION_TYPES = [ - ('C', 'add'), - ('R', 'view'), - ('U', 'change'), - ('D', 'delete') + ('add', 'add'), + ('view', 'view'), + ('change', 'change'), + ('delete', 'delete') ] model = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name='+') @@ -72,7 +72,7 @@ class Permission(models.Model): unique_together = ('model', 'permission', 'type', 'field') def clean(self): - if self.field and self.type not in {'R', 'U'}: + 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): From d826dc9d2076fcc032eafaf9cbe80f38a5c8f2f1 Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Wed, 18 Sep 2019 16:40:21 +0200 Subject: [PATCH 003/119] [permission] Permission admin view --- apps/permission/admin.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/permission/admin.py b/apps/permission/admin.py index 8c38f3f3..4594468d 100644 --- a/apps/permission/admin.py +++ b/apps/permission/admin.py @@ -1,3 +1,11 @@ from django.contrib import admin -# Register your models here. +from .models import Permission + + +@admin.register(Permission) +class PermissionAdmin(admin.ModelAdmin): + """ + Admin customisation for Permission + """ + list_display = ('type', 'model', 'field', 'permission') From 1ac63cbed1981005e4bf2b6a0518177b41b81598 Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Wed, 18 Sep 2019 16:41:01 +0200 Subject: [PATCH 004/119] [member] Handle unlimited memberships --- apps/member/models.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/member/models.py b/apps/member/models.py index 2e84dc75..d1c3dea2 100644 --- a/apps/member/models.py +++ b/apps/member/models.py @@ -146,7 +146,10 @@ class Membership(models.Model): ) def valid(self): - return self.date_start <= datetime.datetime.now() < self.date_end + if self.date_end is not None: + return self.date_start <= datetime.datetime.now() < self.date_end + else: + return self.date_start <= datetime.datetime.now() class Meta: verbose_name = _('membership') From 3766a1905df1abff26affb74cae5ecd41e194446 Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Wed, 18 Sep 2019 16:44:04 +0200 Subject: [PATCH 005/119] [permission] track migrations directory --- apps/permission/migrations/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 apps/permission/migrations/__init__.py diff --git a/apps/permission/migrations/__init__.py b/apps/permission/migrations/__init__.py new file mode 100644 index 00000000..e69de29b From 94c3a994470efc9f8d58b428a2ccda5caca0b5b9 Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Sun, 9 Feb 2020 17:35:15 +0100 Subject: [PATCH 006/119] [permission] Rewrite with comments --- apps/permission/models.py | 87 +++++++++++++++++++++++++-------------- 1 file changed, 55 insertions(+), 32 deletions(-) diff --git a/apps/permission/models.py b/apps/permission/models.py index 73000cbd..b75496ab 100644 --- a/apps/permission/models.py +++ b/apps/permission/models.py @@ -1,4 +1,6 @@ +import functools import json +import operator from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError @@ -9,15 +11,19 @@ from django.utils.translation import gettext_lazy as _ class InstancedPermission: - def __init__(self, model, permission, type, field): + def __init__(self, model, query, type, field): self.model = model - self.permission = permission + self.query = query self.type = type self.field = field def applies(self, obj, permission_type, field_name=None): + """ + Returns True if the permission applies to + the field `field_name` object `obj` + """ if ContentType.objects.get_for_model(obj) != self.model: - # The permission does not apply to the object + # The permission does not apply to the model return False if self.permission is None: if permission_type == self.type: @@ -27,22 +33,10 @@ class InstancedPermission: return True else: return False - elif isinstance(self.permission, dict): - for field in self.permission: - value = getattr(obj, field) - if isinstance(value, models.Model): - value = value.pk - if value != self.permission[field]: - return False - elif isinstance(self.permission, type(obj.pk)): - if obj.pk != self.permission: - return False - if permission_type == self.type: - if field_name: - return field_name == self.field - else: - return True - return False + elif obj in self.model.objects.get(self.query): + return True + else: + return False def __repr__(self): if self.field: @@ -62,11 +56,24 @@ class Permission(models.Model): model = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name='+') + # A json encoded Q object with the following grammar + # permission -> [] | {} (the empty permission representing all objects) + # permission -> ['AND', permission, …] + # -> ['OR', permission, …] + # -> ['NOT', permission] + # permission -> {key: value, …} + # key -> string + # value -> int | string | bool | null + # -> [parameter] + # + # Examples: + # Q(is_admin=True) := {'is_admin': ['TYPE', 'bool', 'True']} + # ~Q(is_admin=True) := ['NOT', {'is_admin': ['TYPE', 'bool', 'True']}] permission = models.TextField() - type = models.CharField(max_length=15, choices=PERMISSION_TYPES) + type = models.CharField(max_length=16, choices=PERMISSION_TYPES) - field = models.CharField(max_length=255, blank=True) + field = models.CharField(max_length=256, blank=True) class Meta: unique_together = ('model', 'permission', 'type', 'field') @@ -80,22 +87,38 @@ class Permission(models.Model): super().save() def _about(_self, _permission, **kwargs): - if _permission[0] == 'all': + self = _self + permission = _permission + if len(permission) == 0: + # The permission is either [] or {} and + # applies to all objects of the model + # to represent this we return None return None - elif _permission[0] == 'pk': - if _permission[1] in kwargs: - return kwargs[_permission[1]].pk - else: - return None - elif _permission[0] == 'filter': - return {field: _self._about(_permission[1][field], **kwargs) for field in _permission[1]} + if isinstance(permission, list): + if permission[0] == 'AND': + return functools.reduce(operator.and_, [self._about(permission, **kwargs) for permission in permission[1:]]) + elif permission[0] == 'OR': + return functools.reduce(operator.or_, [self._about(permission, **kwargs) for permission in permission[1:]]) + elif permission[0] == 'NOT': + return ~self._about(permission[1], **kwargs) + elif isinstance(permission, dict): + q_kwargs = {} + for key in permission: + value = permission[key] + if isinstance(value, list): + # It is a parameter we query its primary key + q_kwargs[key] = kwargs[value[0]].pk + else: + q_kwargs[key] = value + return Q(**q_kwargs) else: - return _permission + # TODO: find a better way to crash here + raise Exception("Permission {} is wrong".format(self.permission)) def about(self, **kwargs): permission = json.loads(self.permission) - permission = self._about(permission, **kwargs) - return InstancedPermission(self.model, permission, self.type, self.field) + query = self._about(permission, **kwargs) + return InstancedPermission(self.model, query, self.type, self.field) def __str__(self): if self.field: From 72955ae2d6e58901775a43707716e2141dd23eb7 Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Sun, 9 Feb 2020 18:14:36 +0100 Subject: [PATCH 007/119] [permission] Renamed Permission.permission and added description field --- apps/permission/models.py | 68 +++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/apps/permission/models.py b/apps/permission/models.py index b75496ab..5c016806 100644 --- a/apps/permission/models.py +++ b/apps/permission/models.py @@ -57,26 +57,28 @@ class Permission(models.Model): model = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name='+') # A json encoded Q object with the following grammar - # permission -> [] | {} (the empty permission representing all objects) - # permission -> ['AND', permission, …] - # -> ['OR', permission, …] - # -> ['NOT', permission] - # permission -> {key: value, …} - # key -> string - # value -> int | string | bool | null - # -> [parameter] + # query -> [] | {} (the empty query representing all objects) + # query -> ['AND', query, …] + # -> ['OR', query, …] + # -> ['NOT', query] + # query -> {key: value, …} + # key -> string + # value -> int | string | bool | null + # -> [parameter] # # Examples: # Q(is_admin=True) := {'is_admin': ['TYPE', 'bool', 'True']} # ~Q(is_admin=True) := ['NOT', {'is_admin': ['TYPE', 'bool', 'True']}] - permission = models.TextField() + query = models.TextField() - type = models.CharField(max_length=16, choices=PERMISSION_TYPES) + type = models.CharField(max_length=15, choices=PERMISSION_TYPES) - field = models.CharField(max_length=256, blank=True) + field = models.CharField(max_length=255, blank=True) + + description = models.CharField(max_length=255, blank=True) class Meta: - unique_together = ('model', 'permission', 'type', 'field') + unique_together = ('model', 'query', 'type', 'field') def clean(self): if self.field and self.type not in {'view', 'change'}: @@ -86,25 +88,25 @@ class Permission(models.Model): self.full_clean() super().save() - def _about(_self, _permission, **kwargs): + def _about(_self, _query, **kwargs): self = _self - permission = _permission - if len(permission) == 0: - # The permission is either [] or {} and + query = _query + if len(query) == 0: + # The query is either [] or {} and # applies to all objects of the model # to represent this we return None return None - if isinstance(permission, list): - if permission[0] == 'AND': - return functools.reduce(operator.and_, [self._about(permission, **kwargs) for permission in permission[1:]]) - elif permission[0] == 'OR': - return functools.reduce(operator.or_, [self._about(permission, **kwargs) for permission in permission[1:]]) - elif permission[0] == 'NOT': - return ~self._about(permission[1], **kwargs) - elif isinstance(permission, dict): + if isinstance(query, list): + if query[0] == 'AND': + return functools.reduce(operator.and_, [self._about(query, **kwargs) for query in query[1:]]) + elif query[0] == 'OR': + return functools.reduce(operator.or_, [self._about(query, **kwargs) for query in query[1:]]) + elif query[0] == 'NOT': + return ~self._about(query[1], **kwargs) + elif isinstance(query, dict): q_kwargs = {} - for key in permission: - value = permission[key] + for key in query: + value = query[key] if isinstance(value, list): # It is a parameter we query its primary key q_kwargs[key] = kwargs[value[0]].pk @@ -113,18 +115,22 @@ class Permission(models.Model): return Q(**q_kwargs) else: # TODO: find a better way to crash here - raise Exception("Permission {} is wrong".format(self.permission)) + raise Exception("query {} is wrong".format(self.query)) def about(self, **kwargs): - permission = json.loads(self.permission) - query = self._about(permission, **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) def __str__(self): if self.field: - return _("Can {type} {model}.{field} in {permission}").format(type=self.type, model=self.model, field=self.field, permission=self.permission) + 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 {permission}").format(type=self.type, model=self.model, permission=self.permission) + return _("Can {type} {model} in {query}").format(type=self.type, model=self.model, query=self.query) class UserPermission(models.Model): From 2b49effebbf3e09bc0cfb2bd64a7a8f56cebc309 Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Sun, 9 Feb 2020 18:30:37 +0100 Subject: [PATCH 008/119] [permission] Update admin --- apps/permission/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/permission/admin.py b/apps/permission/admin.py index 4594468d..e93de0c5 100644 --- a/apps/permission/admin.py +++ b/apps/permission/admin.py @@ -8,4 +8,4 @@ class PermissionAdmin(admin.ModelAdmin): """ Admin customisation for Permission """ - list_display = ('type', 'model', 'field', 'permission') + list_display = ('type', 'model', 'field', 'description') From 982a5ae0099eca14d58dcb2cb0f9d50d88c10934 Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Thu, 13 Feb 2020 15:59:19 +0100 Subject: [PATCH 009/119] [permission] Add F object support --- apps/permission/models.py | 46 ++++++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/apps/permission/models.py b/apps/permission/models.py index 5c016806..2ca17e4c 100644 --- a/apps/permission/models.py +++ b/apps/permission/models.py @@ -5,7 +5,7 @@ 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 Q +from django.db.models import F, Q from django.utils.translation import gettext_lazy as _ @@ -58,13 +58,20 @@ class Permission(models.Model): # A json encoded Q object with the following grammar # query -> [] | {} (the empty query representing all objects) - # query -> ['AND', query, …] - # -> ['OR', query, …] - # -> ['NOT', query] - # query -> {key: value, …} - # key -> string - # value -> int | string | bool | null - # -> [parameter] + # 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 + # | {'F': oper} An F object + # oper -> [string] A parameter + # | ['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_admin=True) := {'is_admin': ['TYPE', 'bool', 'True']} @@ -88,6 +95,26 @@ class Permission(models.Model): self.full_clean() super().save() + @staticmethod + def compute_f(_oper, **kwargs): + oper = _oper + if isinstance(oper, list): + if len(oper) == 1: + return kwargs[oper[0]].pk + elif len(oper) >= 2: + if oper[0] == 'ADD': + return functools.reduce(operator.add, [compute_f(oper, **kwargs) for oper in oper[1:]]) + elif oper[0] == 'SUB': + return compute_f(oper[1], **kwargs) - compute_f(oper[2], **kwargs) + elif oper[0] == 'MUL': + return functools.reduce(operator.mul, [compute_f(oper, **kwargs) for oper in oper[1:]]) + elif oper[0] == 'F': + return F(oper[1]) + else: + return oper + # TODO: find a better way to crash here + raise Exception("F is wrong") + def _about(_self, _query, **kwargs): self = _self query = _query @@ -110,6 +137,9 @@ class Permission(models.Model): if isinstance(value, list): # It is a parameter we query its primary key q_kwargs[key] = kwargs[value[0]].pk + elif isinstance(value, dict): + # It is an F object + q_kwargs[key] = compute_f(query['F'], **kwargs) else: q_kwargs[key] = value return Q(**q_kwargs) From 8a9ad0a6e50ef24c3cc2c1519c2be0f0d955e19c Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Sat, 7 Mar 2020 09:30:22 +0100 Subject: [PATCH 010/119] [permission] Handle add rights --- apps/permission/models.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/apps/permission/models.py b/apps/permission/models.py index 2ca17e4c..2fcb23cb 100644 --- a/apps/permission/models.py +++ b/apps/permission/models.py @@ -22,6 +22,9 @@ class InstancedPermission: Returns True if the permission applies to the field `field_name` object `obj` """ + if self.type == 'add': + if permission_type == self.type: + return self.query(obj) if ContentType.objects.get_for_model(obj) != self.model: # The permission does not apply to the model return False @@ -118,6 +121,9 @@ class Permission(models.Model): def _about(_self, _query, **kwargs): self = _self query = _query + if self.type == 'add'): + # Handle add permission differently + return self._about_add(query, **kwargs) if len(query) == 0: # The query is either [] or {} and # applies to all objects of the model @@ -147,6 +153,38 @@ class Permission(models.Model): # TODO: find a better way to crash here raise Exception("query {} is wrong".format(self.query)) + def _about_add(_self, _query, **kwargs): + self = _self + query = _query + if len(query) == 0: + return lambda _: True + if isinstance(query, list): + if query[0] == 'AND': + return lambda obj: functools.reduce(operator.and_, [self._about_add(query, **kwargs)(obj) for query in query[1:]]) + elif query[0] == 'OR': + return lambda obj: functools.reduce(operator.or_, [self._about_add(query, **kwargs)(obj) for query in query[1:]]) + elif query[0] == 'NOT': + return lambda obj: not self._about_add(query[1], **kwargs)(obj) + elif isinstance(query, dict): + q_kwargs = {} + for key in query: + value = query[key] + if isinstance(value, list): + # It is a parameter we query its primary key + q_kwargs[key] = kwargs[value[0]].pk + elif isinstance(value, dict): + # It is an F object + q_kwargs[key] = compute_f(query['F'], **kwargs) + else: + q_kwargs[key] = value + def func(obj): + nonlocal q_kwargs + for arg in q_kwargs: + if getattr(obj, arg) != q_kwargs(arg): + return False + return True + return func + def about(self, **kwargs): """ Return an InstancedPermission with the parameters From 5df1f42f435a7ff0c4e895d06c7de6e45a3baeb0 Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Sat, 7 Mar 2020 10:48:38 +0100 Subject: [PATCH 011/119] [permission] Syntax error --- apps/permission/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/permission/models.py b/apps/permission/models.py index 2fcb23cb..000fe69f 100644 --- a/apps/permission/models.py +++ b/apps/permission/models.py @@ -121,7 +121,7 @@ class Permission(models.Model): def _about(_self, _query, **kwargs): self = _self query = _query - if self.type == 'add'): + if self.type == 'add': # Handle add permission differently return self._about_add(query, **kwargs) if len(query) == 0: From 9d61e217e9994eb3d60d0c53c65af0f294601f84 Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Sat, 7 Mar 2020 11:21:19 +0100 Subject: [PATCH 012/119] [permission] Only split permission up to 3 --- apps/member/backends.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/member/backends.py b/apps/member/backends.py index 0b2edad8..9ef9706f 100644 --- a/apps/member/backends.py +++ b/apps/member/backends.py @@ -21,7 +21,7 @@ class PermissionBackend(object): def has_perm(self, user_obj, perm, obj=None): if obj is None: return False - perm = perm.split('_') + perm = perm.split('_', 3) perm_type = perm[1] perm_field = perm[2] if len(perm) == 3 else None return any(permission.applies(obj, perm_type, perm_field) for obj in self.permissions(user_obj, obj)) From 30ce17b644c238183f5e0904c1df621dc209d323 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Sat, 7 Mar 2020 13:12:17 +0100 Subject: [PATCH 013/119] Update a lot of things --- apps/logs/signals.py | 4 +++ apps/member/admin.py | 3 +- apps/member/backends.py | 36 +++++++++++--------- apps/member/models.py | 4 +-- apps/permission/admin.py | 3 ++ apps/permission/apps.py | 3 ++ apps/permission/models.py | 70 ++++++++++++++++++-------------------- apps/permission/tests.py | 3 ++ apps/permission/views.py | 3 ++ note_kfet/settings/base.py | 7 ++-- requirements.txt | 1 - 11 files changed, 75 insertions(+), 62 deletions(-) diff --git a/apps/logs/signals.py b/apps/logs/signals.py index 55e0f041..13194e5b 100644 --- a/apps/logs/signals.py +++ b/apps/logs/signals.py @@ -78,6 +78,10 @@ def save_object(sender, instance, **kwargs): user, ip = get_user_and_ip(sender) + from django.contrib.auth.models import AnonymousUser + if isinstance(user, AnonymousUser): + user = None + if user is not None and instance._meta.label_lower == "auth.user" and previous: # Don't save last login modifications if instance.last_login != previous.last_login: diff --git a/apps/member/admin.py b/apps/member/admin.py index fb107377..70b00459 100644 --- a/apps/member/admin.py +++ b/apps/member/admin.py @@ -6,7 +6,7 @@ from django.contrib.auth.admin import UserAdmin from django.contrib.auth.models import User from .forms import ProfileForm -from .models import Club, Membership, Profile, Role +from .models import Club, Membership, Profile, Role, RolePermissions class ProfileInline(admin.StackedInline): @@ -40,3 +40,4 @@ admin.site.register(User, CustomUserAdmin) admin.site.register(Club) admin.site.register(Membership) admin.site.register(Role) +admin.site.register(RolePermissions) diff --git a/apps/member/backends.py b/apps/member/backends.py index 9ef9706f..db227cdb 100644 --- a/apps/member/backends.py +++ b/apps/member/backends.py @@ -1,33 +1,37 @@ -from django.contribs.contenttype.models import ContentType +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + from member.models import Club, Membership, RolePermissions +from django.contrib.auth.backends import ModelBackend -class PermissionBackend(object): +class PermissionBackend(ModelBackend): supports_object_permissions = True supports_anonymous_user = False supports_inactive_user = False - def authenticate(self, username, password): - return None - - def permissions(self, user, obj): - for membership in user.memberships.all(): - if not membership.valid() or membership.role is None: + def permissions(self, user): + for membership in Membership.objects.filter(user=user).all(): + if not membership.valid() or membership.roles is None: continue - for permission in RolePermissions.objects.get(role=membership.role).permissions.objects.all(): - permission = permission.about(user=user, club=membership.club) - yield permission + for role_permissions in RolePermissions.objects.filter(role=membership.roles).all(): + for permission in role_permissions.permissions.all(): + permission = permission.about(user=user, club=membership.club) + yield permission def has_perm(self, user_obj, perm, obj=None): + if user_obj.is_superuser: + return True + if obj is None: return False perm = perm.split('_', 3) perm_type = perm[1] perm_field = perm[2] if len(perm) == 3 else None - return any(permission.applies(obj, perm_type, perm_field) for obj in self.permissions(user_obj, obj)) + return any(permission.applies(obj, perm_type, perm_field) for permission in self.permissions(user_obj)) + + def has_module_perms(self, user_obj, app_label): + return False def get_all_permissions(self, user_obj, obj=None): - if obj is None: - return [] - else: - return list(self.permissions(user_obj, obj)) + return list(self.permissions(user_obj)) diff --git a/apps/member/models.py b/apps/member/models.py index c90ab15c..1ca82af0 100644 --- a/apps/member/models.py +++ b/apps/member/models.py @@ -154,9 +154,9 @@ class Membership(models.Model): def valid(self): if self.date_end is not None: - return self.date_start <= datetime.datetime.now() < self.date_end + return self.date_start.toordinal() <= datetime.datetime.now().toordinal() < self.date_end.toordinal() else: - return self.date_start <= datetime.datetime.now() + return self.date_start.toordinal() <= datetime.datetime.now().toordinal() class Meta: verbose_name = _('membership') diff --git a/apps/permission/admin.py b/apps/permission/admin.py index e93de0c5..f7a9b4b5 100644 --- a/apps/permission/admin.py +++ b/apps/permission/admin.py @@ -1,3 +1,6 @@ +# 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 diff --git a/apps/permission/apps.py b/apps/permission/apps.py index 0f46ef08..c9c912a5 100644 --- a/apps/permission/apps.py +++ b/apps/permission/apps.py @@ -1,3 +1,6 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + from django.apps import AppConfig diff --git a/apps/permission/models.py b/apps/permission/models.py index 000fe69f..9584f59f 100644 --- a/apps/permission/models.py +++ b/apps/permission/models.py @@ -1,3 +1,6 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + import functools import json import operator @@ -24,28 +27,25 @@ class InstancedPermission: """ if self.type == 'add': if permission_type == self.type: - return self.query(obj) + return obj in self.model.modelclass().objects.get(self.query) if ContentType.objects.get_for_model(obj) != self.model: # The permission does not apply to the model return False - if self.permission is None: - if permission_type == self.type: - if field_name is not None: - return field_name == self.field - else: - return True - else: + if permission_type == self.type: + if field_name and field_name != self.field: return False - elif obj in self.model.objects.get(self.query): - return True + return obj in self.model.model_class().objects.filter(self.query).all() else: return False def __repr__(self): if self.field: - return _("Can {type} {model}.{field} in {permission}").format(type=self.type, model=self.model, field=self.field, permission=self.permission) + 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 {permission}").format(type=self.type, model=self.model, permission=self.permission) + return _("Can {type} {model} in {query}").format(type=self.type, model=self.model, query=self.query) + + def __str__(self): + return self.__repr__() class Permission(models.Model): @@ -61,24 +61,24 @@ class Permission(models.Model): # 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 -> ["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 - # | {'F': oper} An F object + # | {"F": oper} An F object # oper -> [string] A parameter - # | ['ADD', oper, …] Sum multiple F objects or literal - # | ['SUB', oper, oper] Substract two F objects or literal - # | ['MUL', oper, …] Multiply F objects or literals + # | ["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 + # | ["F", string] A field # # Examples: - # Q(is_admin=True) := {'is_admin': ['TYPE', 'bool', 'True']} - # ~Q(is_admin=True) := ['NOT', {'is_admin': ['TYPE', 'bool', 'True']}] + # 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) @@ -94,23 +94,22 @@ class Permission(models.Model): 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): + def save(self, **kwargs): self.full_clean() super().save() @staticmethod - def compute_f(_oper, **kwargs): - oper = _oper + def compute_f(oper, **kwargs): if isinstance(oper, list): if len(oper) == 1: return kwargs[oper[0]].pk elif len(oper) >= 2: if oper[0] == 'ADD': - return functools.reduce(operator.add, [compute_f(oper, **kwargs) for oper in oper[1:]]) + return functools.reduce(operator.add, [Permission.compute_f(oper, **kwargs) for oper in oper[1:]]) elif oper[0] == 'SUB': - return compute_f(oper[1], **kwargs) - compute_f(oper[2], **kwargs) + return Permission.compute_f(oper[1], **kwargs) - Permission.compute_f(oper[2], **kwargs) elif oper[0] == 'MUL': - return functools.reduce(operator.mul, [compute_f(oper, **kwargs) for oper in oper[1:]]) + return functools.reduce(operator.mul, [Permission.compute_f(oper, **kwargs) for oper in oper[1:]]) elif oper[0] == 'F': return F(oper[1]) else: @@ -118,9 +117,7 @@ class Permission(models.Model): # TODO: find a better way to crash here raise Exception("F is wrong") - def _about(_self, _query, **kwargs): - self = _self - query = _query + def _about(self, query, **kwargs): if self.type == 'add': # Handle add permission differently return self._about_add(query, **kwargs) @@ -145,7 +142,7 @@ class Permission(models.Model): q_kwargs[key] = kwargs[value[0]].pk elif isinstance(value, dict): # It is an F object - q_kwargs[key] = compute_f(query['F'], **kwargs) + q_kwargs[key] = Permission.compute_f(query['F'], **kwargs) else: q_kwargs[key] = value return Q(**q_kwargs) @@ -153,16 +150,15 @@ class Permission(models.Model): # TODO: find a better way to crash here raise Exception("query {} is wrong".format(self.query)) - def _about_add(_self, _query, **kwargs): - self = _self + def _about_add(self, _query, **kwargs): query = _query if len(query) == 0: return lambda _: True if isinstance(query, list): if query[0] == 'AND': - return lambda obj: functools.reduce(operator.and_, [self._about_add(query, **kwargs)(obj) for query in query[1:]]) + return lambda obj: functools.reduce(operator.and_, [self._about_add(q, **kwargs)(obj) for q in query[1:]]) elif query[0] == 'OR': - return lambda obj: functools.reduce(operator.or_, [self._about_add(query, **kwargs)(obj) for query in query[1:]]) + return lambda obj: functools.reduce(operator.or_, [self._about_add(q, **kwargs)(obj) for q in query[1:]]) elif query[0] == 'NOT': return lambda obj: not self._about_add(query[1], **kwargs)(obj) elif isinstance(query, dict): @@ -174,7 +170,7 @@ class Permission(models.Model): q_kwargs[key] = kwargs[value[0]].pk elif isinstance(value, dict): # It is an F object - q_kwargs[key] = compute_f(query['F'], **kwargs) + q_kwargs[key] = Permission.compute_f(query['F'], **kwargs) else: q_kwargs[key] = value def func(obj): diff --git a/apps/permission/tests.py b/apps/permission/tests.py index 7ce503c2..b5d5752e 100644 --- a/apps/permission/tests.py +++ b/apps/permission/tests.py @@ -1,3 +1,6 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + from django.test import TestCase # Create your tests here. diff --git a/apps/permission/views.py b/apps/permission/views.py index 91ea44a2..8d81fd33 100644 --- a/apps/permission/views.py +++ b/apps/permission/views.py @@ -1,3 +1,6 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + from django.shortcuts import render # Create your views here. diff --git a/note_kfet/settings/base.py b/note_kfet/settings/base.py index 63b7ff24..20937fac 100644 --- a/note_kfet/settings/base.py +++ b/note_kfet/settings/base.py @@ -37,7 +37,6 @@ INSTALLED_APPS = [ # External apps 'polymorphic', - 'guardian', 'reversion', 'crispy_forms', 'django_tables2', @@ -134,8 +133,8 @@ PASSWORD_HASHERS = [ # Django Guardian object permissions AUTHENTICATION_BACKENDS = ( - 'django.contrib.auth.backends.ModelBackend', # this is default - 'guardian.backends.ObjectPermissionBackend', + #'django.contrib.auth.backends.ModelBackend', # this is default + 'member.backends.PermissionBackend', 'cas.backends.CASBackend', ) @@ -153,8 +152,6 @@ REST_FRAMEWORK = { ANONYMOUS_USER_NAME = None # Disable guardian anonymous user -GUARDIAN_GET_CONTENT_TYPE = 'polymorphic.contrib.guardian.get_polymorphic_base_content_type' - # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ diff --git a/requirements.txt b/requirements.txt index 244690bc..9a5eaa22 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,6 @@ django-cas-server==1.1.0 django-crispy-forms==1.7.2 django-extensions==2.1.9 django-filter==2.2.0 -django-guardian==2.1.0 django-polymorphic==2.0.3 djangorestframework==3.9.0 django-rest-polymorphic==0.1.8 From 205fd5c17a0bcd83e69e1b2934078a7daea6b6a8 Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Mon, 9 Mar 2020 19:03:36 +0100 Subject: [PATCH 014/119] initiate submodule for scripts --- .gitmodules | 3 +++ apps/scripts | 1 + 2 files changed, 4 insertions(+) create mode 100644 .gitmodules create mode 160000 apps/scripts diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..94cf1be6 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "apps/scripts"] + path = apps/scripts + url = git@gitlab.crans.org:bde/nk20-scripts.git diff --git a/apps/scripts b/apps/scripts new file mode 160000 index 00000000..123466cf --- /dev/null +++ b/apps/scripts @@ -0,0 +1 @@ +Subproject commit 123466cfa914422422cd372197e64adf65ef05f7 From 462a9e0f2d6f9693917992378370444e74206685 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Mon, 9 Mar 2020 22:13:11 +0100 Subject: [PATCH 015/119] Fix CAS settings --- note_kfet/settings/__init__.py | 5 +++++ note_kfet/settings/base.py | 2 ++ 2 files changed, 7 insertions(+) diff --git a/note_kfet/settings/__init__.py b/note_kfet/settings/__init__.py index 6d871599..c1df7477 100644 --- a/note_kfet/settings/__init__.py +++ b/note_kfet/settings/__init__.py @@ -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 * diff --git a/note_kfet/settings/base.py b/note_kfet/settings/base.py index 4fe12fbf..3a5c3200 100644 --- a/note_kfet/settings/base.py +++ b/note_kfet/settings/base.py @@ -39,6 +39,8 @@ INSTALLED_APPS = [ 'polymorphic', 'crispy_forms', 'django_tables2', + 'cas_server', + 'cas', # Django contrib 'django.contrib.admin', 'django.contrib.admindocs', From 83b1ad47515b6fa9ce875709b4813bbe92d823ce Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Mon, 9 Mar 2020 22:23:47 +0100 Subject: [PATCH 016/119] Multi-connexions --- note_kfet/urls.py | 4 ++-- templates/registration/login.html | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/note_kfet/urls.py b/note_kfet/urls.py index 896c0655..407659f8 100644 --- a/note_kfet/urls.py +++ b/note_kfet/urls.py @@ -37,8 +37,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: diff --git a/templates/registration/login.html b/templates/registration/login.html index 04ef8d7d..5a4322d1 100644 --- a/templates/registration/login.html +++ b/templates/registration/login.html @@ -17,6 +17,10 @@ SPDX-License-Identifier: GPL-2.0-or-later

{% endif %} +
+ Vous pouvez aussi vous connecter via l'authentification centralisée en suivant ce lien. +
+
{% csrf_token %} {{ form | crispy }} From fffd674c44c034361ede6f010f8ea3c16365befd Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Mon, 9 Mar 2020 22:29:14 +0100 Subject: [PATCH 017/119] Fix initial fixture hardcode --- apps/note/fixtures/initial.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/note/fixtures/initial.json b/apps/note/fixtures/initial.json index f80332c0..01242b22 100644 --- a/apps/note/fixtures/initial.json +++ b/apps/note/fixtures/initial.json @@ -3,7 +3,7 @@ "model": "note.note", "pk": 1, "fields": { - "polymorphic_ctype": 39, + "polymorphic_ctype": 37, "balance": 0, "is_active": true, "display_image": "", @@ -14,7 +14,7 @@ "model": "note.note", "pk": 2, "fields": { - "polymorphic_ctype": 39, + "polymorphic_ctype": 37, "balance": 0, "is_active": true, "display_image": "", @@ -25,7 +25,7 @@ "model": "note.note", "pk": 3, "fields": { - "polymorphic_ctype": 39, + "polymorphic_ctype": 37, "balance": 0, "is_active": true, "display_image": "", @@ -36,7 +36,7 @@ "model": "note.note", "pk": 4, "fields": { - "polymorphic_ctype": 39, + "polymorphic_ctype": 37, "balance": 0, "is_active": true, "display_image": "", @@ -47,7 +47,7 @@ "model": "note.note", "pk": 5, "fields": { - "polymorphic_ctype": 38, + "polymorphic_ctype": 36, "balance": 0, "is_active": true, "display_image": "", @@ -58,7 +58,7 @@ "model": "note.note", "pk": 6, "fields": { - "polymorphic_ctype": 38, + "polymorphic_ctype": 36, "balance": 0, "is_active": true, "display_image": "", From 20e2d41563a1f38c4c36b82a434080b3666d5148 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Tue, 10 Mar 2020 00:01:40 +0100 Subject: [PATCH 018/119] Use a middleware rather than inspect the stack to get current user and IP --- apps/logs/middlewares.py | 55 ++++++++++++++++++++++++++++++++++ apps/logs/signals.py | 45 ++++------------------------ note_kfet/settings/__init__.py | 6 +++- 3 files changed, 65 insertions(+), 41 deletions(-) create mode 100644 apps/logs/middlewares.py diff --git a/apps/logs/middlewares.py b/apps/logs/middlewares.py new file mode 100644 index 00000000..69bbef92 --- /dev/null +++ b/apps/logs/middlewares.py @@ -0,0 +1,55 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.conf import settings +from django.contrib.auth.models import AnonymousUser +from threading import local + + +USER_ATTR_NAME = getattr(settings, 'LOCAL_USER_ATTR_NAME', '_current_user') +IP_ATTR_NAME = getattr(settings, 'LOCAL_IP_ATTR_NAME', '_current_ip') + +_thread_locals = local() + + +def _set_current_user_and_ip(user=None, ip=None): + """ + Sets current user in local thread. + Can be used as a hook e.g. for shell jobs (when request object is not + available). + """ + setattr(_thread_locals, USER_ATTR_NAME, user) + setattr(_thread_locals, IP_ATTR_NAME, ip) + + +def get_current_user(): + return getattr(_thread_locals, USER_ATTR_NAME, None) + + +def get_current_ip(): + return getattr(_thread_locals, IP_ATTR_NAME, None) + + +def get_current_authenticated_user(): + current_user = get_current_user() + if isinstance(current_user, AnonymousUser): + return None + return current_user + + +class LogsMiddleware(object): + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + user = request.user + if 'HTTP_X_FORWARDED_FOR' in request.META: + ip = request.META.get('HTTP_X_FORWARDED_FOR') + else: + ip = request.META.get('REMOTE_ADDR') + + _set_current_user_and_ip(user, ip) + + response = self.get_response(request) + + return response diff --git a/apps/logs/signals.py b/apps/logs/signals.py index 415e7c1c..e4e47e18 100644 --- a/apps/logs/signals.py +++ b/apps/logs/signals.py @@ -1,48 +1,15 @@ # 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 .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 - - EXCLUDED = [ 'admin.logentry', 'authtoken.token', @@ -75,13 +42,11 @@ def save_object(sender, instance, **kwargs): if instance._meta.label_lower in EXCLUDED: return + print("LOGGING SOMETHING") + previous = instance._previous - user, ip = get_user_and_ip(sender) - - from django.contrib.auth.models import AnonymousUser - if isinstance(user, AnonymousUser): - user = None + user, ip = get_current_authenticated_user(), get_current_ip() if user is not None and instance._meta.label_lower == "auth.user" and previous: # Don't save last login modifications @@ -111,7 +76,7 @@ def delete_object(sender, instance, **kwargs): if instance._meta.label_lower in EXCLUDED: return - user, ip = get_user_and_ip(sender) + user, ip = get_current_authenticated_user(), get_current_ip() instance_json = serializers.serialize('json', [instance, ])[1:-1] Changelog.objects.create(user=user, diff --git a/note_kfet/settings/__init__.py b/note_kfet/settings/__init__.py index c1df7477..b370a430 100644 --- a/note_kfet/settings/__init__.py +++ b/note_kfet/settings/__init__.py @@ -73,7 +73,11 @@ if "cas" in INSTALLED_APPS: 'cas_explained', ] AUTHENTICATION_BACKENDS += ('cas.backends.CASBackend',) - + + +if "logs" in INSTALLED_APPS: + MIDDLEWARE += ('logs.middlewares.LogsMiddleware',) + if "debug_toolbar" in INSTALLED_APPS: MIDDLEWARE.insert(1,"debug_toolbar.middleware.DebugToolbarMiddleware") INTERNAL_IPS = [ '127.0.0.1'] From db69091d9fe8dc7f2b229462fec3eb4cc3fddcc3 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Tue, 10 Mar 2020 01:04:00 +0100 Subject: [PATCH 019/119] When an update is made from the shell, the username of the user connected to the shell is queried --- apps/logs/middlewares.py | 11 +++++------ apps/logs/signals.py | 38 ++++++++++++++++++++++++++++++++------ 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/apps/logs/middlewares.py b/apps/logs/middlewares.py index 69bbef92..cc6e1f4c 100644 --- a/apps/logs/middlewares.py +++ b/apps/logs/middlewares.py @@ -13,11 +13,6 @@ _thread_locals = local() def _set_current_user_and_ip(user=None, ip=None): - """ - Sets current user in local thread. - Can be used as a hook e.g. for shell jobs (when request object is not - available). - """ setattr(_thread_locals, USER_ATTR_NAME, user) setattr(_thread_locals, IP_ATTR_NAME, ip) @@ -38,6 +33,10 @@ def get_current_authenticated_user(): class LogsMiddleware(object): + """ + Ce middleware permet de récupérer l'utilisateur actif ainsi que son adresse IP à chaque connexion. + """ + def __init__(self, get_response): self.get_response = get_response @@ -49,7 +48,7 @@ class LogsMiddleware(object): ip = request.META.get('REMOTE_ADDR') _set_current_user_and_ip(user, ip) - response = self.get_response(request) + _set_current_user_and_ip(None, None) return response diff --git a/apps/logs/signals.py b/apps/logs/signals.py index e4e47e18..85f0c585 100644 --- a/apps/logs/signals.py +++ b/apps/logs/signals.py @@ -5,11 +5,14 @@ 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 +import getpass +from note.models import NoteUser, Alias from .middlewares import get_current_authenticated_user, get_current_ip from .models import Changelog +# Ces modèles ne nécessitent pas de logs EXCLUDED = [ 'admin.logentry', 'authtoken.token', @@ -22,13 +25,15 @@ EXCLUDED = [ 'note.noteclub', 'note.notespecial', 'sessions.session', - 'reversion.revision', - 'reversion.version', ] @receiver(pre_save) def pre_save_object(sender, instance, **kwargs): + """ + Avant la sauvegarde d'un modèle, on récupère l'ancienne instance actuellement en base de données + que l'on garde en mémoire + """ qs = sender.objects.filter(pk=instance.pk).all() if qs.exists(): instance._previous = qs.get() @@ -38,26 +43,43 @@ def pre_save_object(sender, instance, **kwargs): @receiver(post_save) def save_object(sender, instance, **kwargs): + """ + Dès qu'un modèle est sauvegardé, une entrée dans la table `Changelog` est ajouté dans la base de données + afin de répertorier chaque modification effectuée + """ # noinspection PyProtectedMember if instance._meta.label_lower in EXCLUDED: return - print("LOGGING SOMETHING") - + # noinspection PyProtectedMember previous = instance._previous + # 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__regex="^" + username + "$") + if not note.exists(): + print("WARNING: A model attempted to be saved in the DB, but the actor is unknown: " + username) + else: + user = note.get().user + 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 + # Les modèles sont sauvegardés au format JSON previous_json = serializers.serialize('json', [previous, ])[1:-1] if previous else None instance_json = serializers.serialize('json', [instance, ])[1:-1] if previous_json == instance_json: - # No modification + # Pas de log s'il n'y a pas de modification return Changelog.objects.create(user=user, @@ -72,10 +94,14 @@ def save_object(sender, instance, **kwargs): @receiver(post_delete) def delete_object(sender, instance, **kwargs): + """ + Dès qu'un modèle est supprimé, une entrée dans la table `Changelog` est ajouté dans la base de données + """ # noinspection PyProtectedMember if instance._meta.label_lower in EXCLUDED: return + # 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() instance_json = serializers.serialize('json', [instance, ])[1:-1] From 185f6ce4e31c2bbd090a8d53071799e95e0241be Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Tue, 10 Mar 2020 01:07:37 +0100 Subject: [PATCH 020/119] Alias have to be exact --- apps/logs/signals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/logs/signals.py b/apps/logs/signals.py index 85f0c585..89354210 100644 --- a/apps/logs/signals.py +++ b/apps/logs/signals.py @@ -63,7 +63,7 @@ def save_object(sender, instance, **kwargs): # 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__regex="^" + username + "$") + 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: From 94ab2c81f79852cee24e14c1851fe3c00f4eb402 Mon Sep 17 00:00:00 2001 From: Alexandre Iooss Date: Tue, 10 Mar 2020 08:07:09 +0100 Subject: [PATCH 021/119] Use TableView for conso page --- apps/note/forms.py | 32 +------------------------------- apps/note/tables.py | 2 +- apps/note/views.py | 21 ++++++++++----------- templates/note/conso_form.html | 2 +- 4 files changed, 13 insertions(+), 44 deletions(-) diff --git a/apps/note/forms.py b/apps/note/forms.py index 20804412..2e8e4456 100644 --- a/apps/note/forms.py +++ b/apps/note/forms.py @@ -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 Transaction, TransactionTemplate class AliasForm(forms.ModelForm): @@ -99,33 +99,3 @@ class TransactionForm(forms.ModelForm): }, ), } - - -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, - }, - ), - } diff --git a/apps/note/tables.py b/apps/note/tables.py index e85fcbae..d26ffedc 100644 --- a/apps/note/tables.py +++ b/apps/note/tables.py @@ -6,7 +6,7 @@ from django.db.models import F from django_tables2.utils import A from .models.notes import Alias -from .models.transactions import Transaction, TransactionTemplate +from .models.transactions import Transaction from .templatetags.pretty_money import pretty_money diff --git a/apps/note/views.py b/apps/note/views.py index 09846057..fb5e98c5 100644 --- a/apps/note/views.py +++ b/apps/note/views.py @@ -7,9 +7,11 @@ 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 .forms import TransactionForm, TransactionTemplateForm, ConsoForm -from .models import Transaction, TransactionTemplate, Alias, TemplateTransaction +from .forms import TransactionForm, TransactionTemplateForm +from .models import Transaction, TransactionTemplate, Alias +from .tables import HistoryTable class TransactionCreate(LoginRequiredMixin, CreateView): @@ -121,13 +123,16 @@ class TransactionTemplateUpdateView(LoginRequiredMixin, UpdateView): form_class = TransactionTemplateForm -class ConsoView(LoginRequiredMixin, CreateView): +class ConsoView(LoginRequiredMixin, SingleTableView): """ Consume """ - model = TemplateTransaction + model = Transaction template_name = "note/conso_form.html" - form_class = ConsoForm + + # Transaction history table + table_class = HistoryTable + table_pagination = {"per_page": 10} def get_context_data(self, **kwargs): """ @@ -142,9 +147,3 @@ class ConsoView(LoginRequiredMixin, CreateView): context['no_cache'] = True return context - - def get_success_url(self): - """ - When clicking a button, reload the same page - """ - return reverse('note:consos') diff --git a/templates/note/conso_form.html b/templates/note/conso_form.html index 10b06589..286b4285 100644 --- a/templates/note/conso_form.html +++ b/templates/note/conso_form.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% load i18n static pretty_money %} +{% load i18n static pretty_money django_tables2 %} {# Remove page title #} {% block contenttitle %}{% endblock %} From f3b44f3170c1223c580a39748f2152b13d01ffe4 Mon Sep 17 00:00:00 2001 From: Alexandre Iooss Date: Tue, 10 Mar 2020 08:11:43 +0100 Subject: [PATCH 022/119] Bootstrap for conso page --- templates/base.html | 3 - templates/note/conso_form.html | 174 +++++++++++++++++++++------------ 2 files changed, 114 insertions(+), 63 deletions(-) diff --git a/templates/base.html b/templates/base.html index 887bc970..dbe59c41 100644 --- a/templates/base.html +++ b/templates/base.html @@ -75,9 +75,6 @@ SPDX-License-Identifier: GPL-3.0-or-later - diff --git a/templates/note/conso_form.html b/templates/note/conso_form.html index 286b4285..2c2066e8 100644 --- a/templates/note/conso_form.html +++ b/templates/note/conso_form.html @@ -6,75 +6,129 @@ {% block contenttitle %}{% endblock %} {% block content %} - {# Regroup buttons under categories #} - {% regroup transaction_templates by category as categories %} - - - {% csrf_token %} - -
-
- {% if form.non_field_errors %} -

- {% for error in form.non_field_errors %} - {{ error }} - {% endfor %} -

- {% endif %} - {% for field in form %} -
- {{ field.errors }} -
- {{ field.label_tag }} - {% if field.is_readonly %} -
{{ field.contents }}
- {% else %} - {{ field }} - {% endif %} - {% if field.field.help_text %} -
{{ field.field.help_text|safe }}
- {% endif %} +
+
+
+ {# User details column #} +
+
+ +
+ Paquito (aka. PAC) : -230 €
- {% endfor %} -
+
-
-
- {# Tabs for button categories #} -
-
- + + {# Buttons column #} +
+ {# Show last used buttons #} +
+
+

+ Les boutons les plus utilisés s'afficheront ici. +

+
+
+ + {# Regroup buttons under categories #} + {% regroup transaction_templates by template_type as template_types %} + +
+ {# Tabs for button categories #} +
+ +
+ + {# Tabs content #} +
+
+ {% for template_type in template_types %} +
+
+ {% for button in template_type.list %} + + {% endfor %} +
+
+ {% endfor %} +
+
+ + {# Mode switch #} + +
+
+
+ +
+
+

+ Historique des transactions récentes +

+
+ {% render_table table %} +
+{% endblock %} + +{% block extracss %} + {% endblock %} {% block extrajavascript %} From aa731d1ae4a3ed68b22af39b147c74370ecca8aa Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Tue, 10 Mar 2020 17:16:03 +0100 Subject: [PATCH 023/119] Regexp must begin with `^` --- apps/member/views.py | 2 +- apps/note/api/views.py | 6 +++--- apps/note/views.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/member/views.py b/apps/member/views.py index 21c8de5f..82c15b99 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -300,7 +300,7 @@ class UserAutocomplete(autocomplete.Select2QuerySetView): qs = User.objects.all() if self.q: - qs = qs.filter(username__regex=self.q) + qs = qs.filter(username__regex="^" + self.q) return qs diff --git a/apps/note/api/views.py b/apps/note/api/views.py index cf0136f2..abcf915b 100644 --- a/apps/note/api/views.py +++ b/apps/note/api/views.py @@ -69,8 +69,8 @@ class NotePolymorphicViewSet(viewsets.ModelViewSet): 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.lower())) note_type = self.request.query_params.get("type", None) if note_type: @@ -107,7 +107,7 @@ class AliasViewSet(viewsets.ModelViewSet): alias = self.request.query_params.get("alias", ".*") queryset = queryset.filter( - Q(name__regex=alias) | Q(normalized_name__regex=alias.lower())) + Q(name__regex="^" + alias) | Q(normalized_name__regex="^" + alias.lower())) note_id = self.request.query_params.get("note", None) if note_id: diff --git a/apps/note/views.py b/apps/note/views.py index 09846057..ccba8c9f 100644 --- a/apps/note/views.py +++ b/apps/note/views.py @@ -67,7 +67,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) From 6baf437eefedfe3fd7214077577d1885f628a795 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Tue, 10 Mar 2020 23:37:54 +0100 Subject: [PATCH 024/119] Fix Dockerfile --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index a2f45b00..d42bdd1f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 From be877276f8a6b2ca554a46c7d0e28ecda7a51490 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 11 Mar 2020 00:41:37 +0100 Subject: [PATCH 025/119] Fix JSON serialization --- apps/logs/signals.py | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/apps/logs/signals.py b/apps/logs/signals.py index 89354210..1cd74067 100644 --- a/apps/logs/signals.py +++ b/apps/logs/signals.py @@ -2,9 +2,10 @@ # SPDX-License-Identifier: GPL-3.0-or-later 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 import getpass from note.models import NoteUser, Alias @@ -19,11 +20,10 @@ EXCLUDED = [ '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', ] @@ -63,7 +63,7 @@ def save_object(sender, instance, **kwargs): # 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 + "$") + 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: @@ -74,9 +74,14 @@ def save_object(sender, instance, **kwargs): if instance.last_login != previous.last_login: return - # Les modèles sont sauvegardés au format JSON - 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) + instance_json = JSONRenderer().render(CustomSerializer(instance).data) if previous_json == instance_json: # Pas de log s'il n'y a pas de modification @@ -104,7 +109,14 @@ def delete_object(sender, instance, **kwargs): # 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() - 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__' + + instance_json = JSONRenderer().render(CustomSerializer(instance).data) + Changelog.objects.create(user=user, ip=ip, model=ContentType.objects.get_for_model(instance), From 0f1d662d251fdb919d744358c4cc15c0fadf453e Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 11 Mar 2020 00:47:43 +0100 Subject: [PATCH 026/119] Anonymous users don't have any access to the API (fix it later with rights...), fix CI --- apps/note/tables.py | 2 +- note_kfet/settings/__init__.py | 4 ++-- note_kfet/settings/base.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/note/tables.py b/apps/note/tables.py index e85fcbae..d26ffedc 100644 --- a/apps/note/tables.py +++ b/apps/note/tables.py @@ -6,7 +6,7 @@ from django.db.models import F from django_tables2.utils import A from .models.notes import Alias -from .models.transactions import Transaction, TransactionTemplate +from .models.transactions import Transaction from .templatetags.pretty_money import pretty_money diff --git a/note_kfet/settings/__init__.py b/note_kfet/settings/__init__.py index b370a430..ce31b39f 100644 --- a/note_kfet/settings/__init__.py +++ b/note_kfet/settings/__init__.py @@ -79,5 +79,5 @@ if "logs" in INSTALLED_APPS: MIDDLEWARE += ('logs.middlewares.LogsMiddleware',) 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'] diff --git a/note_kfet/settings/base.py b/note_kfet/settings/base.py index 3a5c3200..737350e2 100644 --- a/note_kfet/settings/base.py +++ b/note_kfet/settings/base.py @@ -137,7 +137,7 @@ REST_FRAMEWORK = { # or allow read-only access for unauthenticated users. 'DEFAULT_PERMISSION_CLASSES': [ # TODO Maybe replace it with our custom permissions system - 'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly' + 'rest_framework.permissions.DjangoModelPermissions' ], 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework.authentication.TokenAuthentication', From 38ad8709390128af6fb0b657ab05588ecbc3262b Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 11 Mar 2020 01:03:15 +0100 Subject: [PATCH 027/119] Add one missing model to the API --- apps/note/api/serializers.py | 13 ++++++++++++- apps/note/api/urls.py | 3 ++- apps/note/api/views.py | 14 ++++++++++++-- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/note/api/serializers.py b/apps/note/api/serializers.py index 1696bfee..61257ec4 100644 --- a/apps/note/api/serializers.py +++ b/apps/note/api/serializers.py @@ -5,7 +5,7 @@ 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 class NoteSerializer(serializers.ModelSerializer): @@ -78,6 +78,17 @@ class NotePolymorphicSerializer(PolymorphicSerializer): } +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): """ REST API Serializer for Transaction templates. diff --git a/apps/note/api/urls.py b/apps/note/api/urls.py index 54218796..5e176ec5 100644 --- a/apps/note/api/urls.py +++ b/apps/note/api/urls.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from .views import NotePolymorphicViewSet, AliasViewSet, \ - TransactionViewSet, TransactionTemplateViewSet, MembershipTransactionViewSet + TemplateCategoryViewSet, TransactionViewSet, TransactionTemplateViewSet, MembershipTransactionViewSet def register_note_urls(router, path): @@ -12,6 +12,7 @@ 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) diff --git a/apps/note/api/views.py b/apps/note/api/views.py index abcf915b..4fbb9481 100644 --- a/apps/note/api/views.py +++ b/apps/note/api/views.py @@ -6,9 +6,9 @@ from rest_framework import viewsets from .serializers import NoteSerializer, NotePolymorphicSerializer, NoteClubSerializer, NoteSpecialSerializer, \ NoteUserSerializer, AliasSerializer, \ - TransactionTemplateSerializer, TransactionSerializer, MembershipTransactionSerializer + TemplateCategorySerializer, TransactionTemplateSerializer, TransactionSerializer, MembershipTransactionSerializer from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias -from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction +from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory class NoteViewSet(viewsets.ModelViewSet): @@ -131,6 +131,16 @@ class AliasViewSet(viewsets.ModelViewSet): return queryset +class TemplateCategoryViewSet(viewsets.ModelViewSet): + """ + 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 + + class TransactionTemplateViewSet(viewsets.ModelViewSet): """ REST API View set. From bc97eb1eb4bb3943ad60bf3569c05255386953d0 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 11 Mar 2020 10:08:28 +0100 Subject: [PATCH 028/119] Add logs to the API (this right should only be given to superusers) --- apps/api/urls.py | 2 ++ apps/logs/api/__init__.py | 0 apps/logs/api/serializers.py | 17 +++++++++++++++++ apps/logs/api/urls.py | 11 +++++++++++ apps/logs/api/views.py | 17 +++++++++++++++++ apps/logs/models.py | 6 ++++++ apps/logs/signals.py | 7 ++++--- note_kfet/settings/base.py | 1 + 8 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 apps/logs/api/__init__.py create mode 100644 apps/logs/api/serializers.py create mode 100644 apps/logs/api/urls.py create mode 100644 apps/logs/api/views.py diff --git a/apps/api/urls.py b/apps/api/urls.py index c1b6bf48..bb1fdce2 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -7,6 +7,7 @@ from rest_framework import routers, serializers, viewsets from activity.api.urls import register_activity_urls from member.api.urls import register_members_urls from note.api.urls import register_note_urls +from logs.api.urls import register_logs_urls class UserSerializer(serializers.ModelSerializer): @@ -41,6 +42,7 @@ router.register('user', UserViewSet) register_members_urls(router, 'members') register_activity_urls(router, 'activity') register_note_urls(router, 'note') +register_logs_urls(router, 'logs') app_name = 'api' diff --git a/apps/logs/api/__init__.py b/apps/logs/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/logs/api/serializers.py b/apps/logs/api/serializers.py new file mode 100644 index 00000000..7de7bc1a --- /dev/null +++ b/apps/logs/api/serializers.py @@ -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 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__' diff --git a/apps/logs/api/urls.py b/apps/logs/api/urls.py new file mode 100644 index 00000000..9a0ceaa8 --- /dev/null +++ b/apps/logs/api/urls.py @@ -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) diff --git a/apps/logs/api/views.py b/apps/logs/api/views.py new file mode 100644 index 00000000..60da612b --- /dev/null +++ b/apps/logs/api/views.py @@ -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 viewsets + +from .serializers import ChangelogSerializer +from ..models import Changelog + + +class ChangelogViewSet(viewsets.ModelViewSet): + """ + 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 diff --git a/apps/logs/models.py b/apps/logs/models.py index 9ab3cf6a..10e2651f 100644 --- a/apps/logs/models.py +++ b/apps/logs/models.py @@ -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'), ) diff --git a/apps/logs/signals.py b/apps/logs/signals.py index 1cd74067..41f87cda 100644 --- a/apps/logs/signals.py +++ b/apps/logs/signals.py @@ -69,6 +69,7 @@ def save_object(sender, instance, **kwargs): else: user = note.get().user + # noinspection PyProtectedMember if user is not None and instance._meta.label_lower == "auth.user" and previous: # On n'enregistre pas les connexions if instance.last_login != previous.last_login: @@ -80,8 +81,8 @@ def save_object(sender, instance, **kwargs): model = instance.__class__ fields = '__all__' - previous_json = JSONRenderer().render(CustomSerializer(previous).data) - instance_json = JSONRenderer().render(CustomSerializer(instance).data) + previous_json = JSONRenderer().render(CustomSerializer(previous).data).decode("UTF-8") + instance_json = JSONRenderer().render(CustomSerializer(instance).data).decode("UTF-8") if previous_json == instance_json: # Pas de log s'il n'y a pas de modification @@ -115,7 +116,7 @@ def delete_object(sender, instance, **kwargs): model = instance.__class__ fields = '__all__' - instance_json = JSONRenderer().render(CustomSerializer(instance).data) + instance_json = JSONRenderer().render(CustomSerializer(instance).data).decode("UTF-8") Changelog.objects.create(user=user, ip=ip, diff --git a/note_kfet/settings/base.py b/note_kfet/settings/base.py index 737350e2..84f07b6c 100644 --- a/note_kfet/settings/base.py +++ b/note_kfet/settings/base.py @@ -140,6 +140,7 @@ REST_FRAMEWORK = { 'rest_framework.permissions.DjangoModelPermissions' ], 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.TokenAuthentication', ] } From 417cd5da04c1d74dc52f9f72e6b1ea00c33654a4 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 11 Mar 2020 11:15:03 +0100 Subject: [PATCH 029/119] Improve REST API with filters --- apps/activity/api/views.py | 9 ++++++- apps/api/urls.py | 5 ++++ apps/logs/api/views.py | 3 +++ apps/logs/signals.py | 2 +- apps/logs/urls.py | 8 ------- apps/member/api/views.py | 5 ++++ apps/note/api/serializers.py | 22 ++++++++++++++++- apps/note/api/urls.py | 3 +-- apps/note/api/views.py | 24 +++++++++---------- apps/note/fixtures/initial.json | 12 +++++----- note_kfet/settings/base.py | 6 +++-- note_kfet/urls.py | 3 +-- .../rest_framework/crispy_form.html | 5 ++++ .../django_filters/rest_framework/form.html | 6 +++++ .../django_filters/widgets/multiwidget.html | 1 + 15 files changed, 78 insertions(+), 36 deletions(-) delete mode 100644 apps/logs/urls.py create mode 100644 templates/django_filters/rest_framework/crispy_form.html create mode 100644 templates/django_filters/rest_framework/form.html create mode 100644 templates/django_filters/widgets/multiwidget.html diff --git a/apps/activity/api/views.py b/apps/activity/api/views.py index 6a6c024e..4ee2194d 100644 --- a/apps/activity/api/views.py +++ b/apps/activity/api/views.py @@ -1,7 +1,8 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later - +from django_filters.rest_framework import DjangoFilterBackend from rest_framework import viewsets +from rest_framework.filters import SearchFilter from .serializers import ActivityTypeSerializer, ActivitySerializer, GuestSerializer from ..models import ActivityType, Activity, Guest @@ -15,6 +16,8 @@ class ActivityTypeViewSet(viewsets.ModelViewSet): """ queryset = ActivityType.objects.all() serializer_class = ActivityTypeSerializer + filter_backends = [DjangoFilterBackend] + filterset_fields = ['name', 'can_invite', ] class ActivityViewSet(viewsets.ModelViewSet): @@ -25,6 +28,8 @@ class ActivityViewSet(viewsets.ModelViewSet): """ queryset = Activity.objects.all() serializer_class = ActivitySerializer + filter_backends = [DjangoFilterBackend] + filterset_fields = ['name', 'description', 'activity_type', ] class GuestViewSet(viewsets.ModelViewSet): @@ -35,3 +40,5 @@ class GuestViewSet(viewsets.ModelViewSet): """ queryset = Guest.objects.all() serializer_class = GuestSerializer + filter_backends = [SearchFilter] + search_fields = ['$name', ] diff --git a/apps/api/urls.py b/apps/api/urls.py index bb1fdce2..8e0f6415 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -3,7 +3,9 @@ from django.conf.urls import url, include from django.contrib.auth.models import User +from django_filters.rest_framework import DjangoFilterBackend from rest_framework import routers, serializers, viewsets +from rest_framework.filters import SearchFilter from activity.api.urls import register_activity_urls from member.api.urls import register_members_urls from note.api.urls import register_note_urls @@ -33,6 +35,9 @@ 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', ] # Routers provide an easy way of automatically determining the URL conf. diff --git a/apps/logs/api/views.py b/apps/logs/api/views.py index 60da612b..5b1b3ff6 100644 --- a/apps/logs/api/views.py +++ b/apps/logs/api/views.py @@ -1,6 +1,7 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +from django_filters.rest_framework import DjangoFilterBackend from rest_framework import viewsets from .serializers import ChangelogSerializer @@ -15,3 +16,5 @@ class ChangelogViewSet(viewsets.ModelViewSet): """ queryset = Changelog.objects.all() serializer_class = ChangelogSerializer + filter_backends = [DjangoFilterBackend] + filterset_fields = ['model', 'action', "instance_pk", 'user', 'ip',] diff --git a/apps/logs/signals.py b/apps/logs/signals.py index 41f87cda..ab196291 100644 --- a/apps/logs/signals.py +++ b/apps/logs/signals.py @@ -81,7 +81,7 @@ def save_object(sender, instance, **kwargs): model = instance.__class__ fields = '__all__' - previous_json = JSONRenderer().render(CustomSerializer(previous).data).decode("UTF-8") + 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: diff --git a/apps/logs/urls.py b/apps/logs/urls.py deleted file mode 100644 index 6d76674c..00000000 --- a/apps/logs/urls.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay -# SPDX-License-Identifier: GPL-3.0-or-later - -app_name = 'logs' - -# TODO User interface -urlpatterns = [ -] diff --git a/apps/member/api/views.py b/apps/member/api/views.py index 7e7dcd1d..c85df903 100644 --- a/apps/member/api/views.py +++ b/apps/member/api/views.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from rest_framework import viewsets +from rest_framework.filters import SearchFilter from .serializers import ProfileSerializer, ClubSerializer, RoleSerializer, MembershipSerializer from ..models import Profile, Club, Role, Membership @@ -25,6 +26,8 @@ class ClubViewSet(viewsets.ModelViewSet): """ queryset = Club.objects.all() serializer_class = ClubSerializer + filter_backends = [SearchFilter] + search_fields = ['$name', ] class RoleViewSet(viewsets.ModelViewSet): @@ -35,6 +38,8 @@ class RoleViewSet(viewsets.ModelViewSet): """ queryset = Role.objects.all() serializer_class = RoleSerializer + filter_backends = [SearchFilter] + search_fields = ['$name', ] class MembershipViewSet(viewsets.ModelViewSet): diff --git a/apps/note/api/serializers.py b/apps/note/api/serializers.py index 61257ec4..73beead1 100644 --- a/apps/note/api/serializers.py +++ b/apps/note/api/serializers.py @@ -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, TemplateCategory +from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory, \ + TemplateTransaction class NoteSerializer(serializers.ModelSerializer): @@ -111,6 +112,17 @@ class TransactionSerializer(serializers.ModelSerializer): fields = '__all__' +class TemplateTransactionSerializer(serializers.ModelSerializer): + """ + REST API Serializer for Transactions. + The djangorestframework plugin will analyse the model `TemplateTransaction` and parse all fields in the API. + """ + + class Meta: + model = TemplateTransaction + fields = '__all__' + + class MembershipTransactionSerializer(serializers.ModelSerializer): """ REST API Serializer for Membership transactions. @@ -120,3 +132,11 @@ class MembershipTransactionSerializer(serializers.ModelSerializer): class Meta: model = MembershipTransaction fields = '__all__' + + +class TransactionPolymorphicSerializer(PolymorphicSerializer): + model_serializer_mapping = { + Transaction: TransactionSerializer, + TemplateTransaction: TemplateTransactionSerializer, + MembershipTransaction: MembershipTransactionSerializer, + } diff --git a/apps/note/api/urls.py b/apps/note/api/urls.py index 5e176ec5..796a397f 100644 --- a/apps/note/api/urls.py +++ b/apps/note/api/urls.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from .views import NotePolymorphicViewSet, AliasViewSet, \ - TemplateCategoryViewSet, TransactionViewSet, TransactionTemplateViewSet, MembershipTransactionViewSet + TemplateCategoryViewSet, TransactionViewSet, TransactionTemplateViewSet def register_note_urls(router, path): @@ -15,4 +15,3 @@ def register_note_urls(router, path): router.register(path + '/transaction/category', TemplateCategoryViewSet) router.register(path + '/transaction/transaction', TransactionViewSet) router.register(path + '/transaction/template', TransactionTemplateViewSet) - router.register(path + '/transaction/membership', MembershipTransactionViewSet) diff --git a/apps/note/api/views.py b/apps/note/api/views.py index 4fbb9481..14f64003 100644 --- a/apps/note/api/views.py +++ b/apps/note/api/views.py @@ -2,13 +2,15 @@ # SPDX-License-Identifier: GPL-3.0-or-later from django.db.models import Q +from django_filters.rest_framework import DjangoFilterBackend from rest_framework import viewsets +from rest_framework.filters import SearchFilter from .serializers import NoteSerializer, NotePolymorphicSerializer, NoteClubSerializer, NoteSpecialSerializer, \ NoteUserSerializer, AliasSerializer, \ - TemplateCategorySerializer, TransactionTemplateSerializer, TransactionSerializer, MembershipTransactionSerializer + TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias -from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory +from ..models.transactions import TransactionTemplate, Transaction, TemplateCategory class NoteViewSet(viewsets.ModelViewSet): @@ -139,6 +141,8 @@ class TemplateCategoryViewSet(viewsets.ModelViewSet): """ queryset = TemplateCategory.objects.all() serializer_class = TemplateCategorySerializer + filter_backends = [SearchFilter] + search_fields = ['$name', ] class TransactionTemplateViewSet(viewsets.ModelViewSet): @@ -149,6 +153,8 @@ class TransactionTemplateViewSet(viewsets.ModelViewSet): """ queryset = TransactionTemplate.objects.all() serializer_class = TransactionTemplateSerializer + filter_backends = [DjangoFilterBackend] + filterset_fields = ['name', 'amount', 'display', 'category', ] class TransactionViewSet(viewsets.ModelViewSet): @@ -158,14 +164,6 @@ class TransactionViewSet(viewsets.ModelViewSet): 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', ] diff --git a/apps/note/fixtures/initial.json b/apps/note/fixtures/initial.json index 01242b22..3654fa2f 100644 --- a/apps/note/fixtures/initial.json +++ b/apps/note/fixtures/initial.json @@ -3,7 +3,7 @@ "model": "note.note", "pk": 1, "fields": { - "polymorphic_ctype": 37, + "polymorphic_ctype": 40, "balance": 0, "is_active": true, "display_image": "", @@ -14,7 +14,7 @@ "model": "note.note", "pk": 2, "fields": { - "polymorphic_ctype": 37, + "polymorphic_ctype": 40, "balance": 0, "is_active": true, "display_image": "", @@ -25,7 +25,7 @@ "model": "note.note", "pk": 3, "fields": { - "polymorphic_ctype": 37, + "polymorphic_ctype": 40, "balance": 0, "is_active": true, "display_image": "", @@ -36,7 +36,7 @@ "model": "note.note", "pk": 4, "fields": { - "polymorphic_ctype": 37, + "polymorphic_ctype": 40, "balance": 0, "is_active": true, "display_image": "", @@ -47,7 +47,7 @@ "model": "note.note", "pk": 5, "fields": { - "polymorphic_ctype": 36, + "polymorphic_ctype": 39, "balance": 0, "is_active": true, "display_image": "", @@ -58,7 +58,7 @@ "model": "note.note", "pk": 6, "fields": { - "polymorphic_ctype": 36, + "polymorphic_ctype": 39, "balance": 0, "is_active": true, "display_image": "", diff --git a/note_kfet/settings/base.py b/note_kfet/settings/base.py index 84f07b6c..0694390d 100644 --- a/note_kfet/settings/base.py +++ b/note_kfet/settings/base.py @@ -137,12 +137,14 @@ REST_FRAMEWORK = { # or allow read-only access for unauthenticated users. 'DEFAULT_PERMISSION_CLASSES': [ # TODO Maybe replace it with our custom permissions system - 'rest_framework.permissions.DjangoModelPermissions' + 'rest_framework.permissions.DjangoModelPermissions', ], 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.TokenAuthentication', - ] + ], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 20, } # Internationalization diff --git a/note_kfet/urls.py b/note_kfet/urls.py index 407659f8..da2f9d6c 100644 --- a/note_kfet/urls.py +++ b/note_kfet/urls.py @@ -20,8 +20,7 @@ urlpatterns = [ 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('api/', include('api.urls')), + path('api/', include('api.urls')), ] urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/templates/django_filters/rest_framework/crispy_form.html b/templates/django_filters/rest_framework/crispy_form.html new file mode 100644 index 00000000..171767c0 --- /dev/null +++ b/templates/django_filters/rest_framework/crispy_form.html @@ -0,0 +1,5 @@ +{% load crispy_forms_tags %} +{% load i18n %} + +

{% trans "Field filters" %}

+{% crispy filter.form %} diff --git a/templates/django_filters/rest_framework/form.html b/templates/django_filters/rest_framework/form.html new file mode 100644 index 00000000..b116e353 --- /dev/null +++ b/templates/django_filters/rest_framework/form.html @@ -0,0 +1,6 @@ +{% load i18n %} +

{% trans "Field filters" %}

+
+ {{ filter.form.as_p }} + +
diff --git a/templates/django_filters/widgets/multiwidget.html b/templates/django_filters/widgets/multiwidget.html new file mode 100644 index 00000000..089ddb20 --- /dev/null +++ b/templates/django_filters/widgets/multiwidget.html @@ -0,0 +1 @@ +{% for widget in widget.subwidgets %}{% include widget.template_name %}{% if forloop.first %}-{% endif %}{% endfor %} From 13797598033f7841e66f70fe934e0ff96a19bda6 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 11 Mar 2020 11:25:40 +0100 Subject: [PATCH 030/119] Changelogs are read-only protected --- apps/logs/api/serializers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/logs/api/serializers.py b/apps/logs/api/serializers.py index 7de7bc1a..108f2087 100644 --- a/apps/logs/api/serializers.py +++ b/apps/logs/api/serializers.py @@ -15,3 +15,5 @@ class ChangelogSerializer(serializers.ModelSerializer): 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 From 2c440062cec1b20c3823651b59e0a9a9ab53b0d7 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 11 Mar 2020 11:33:14 +0100 Subject: [PATCH 031/119] Changelogs are read-only protected --- apps/logs/api/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/logs/api/views.py b/apps/logs/api/views.py index 5b1b3ff6..1ddfd7bc 100644 --- a/apps/logs/api/views.py +++ b/apps/logs/api/views.py @@ -8,7 +8,7 @@ from .serializers import ChangelogSerializer from ..models import Changelog -class ChangelogViewSet(viewsets.ModelViewSet): +class ChangelogViewSet(viewsets.ReadOnlyModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer, From b89028b3dca54a5bb67894878947ad59d6e936ff Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 11 Mar 2020 11:37:47 +0100 Subject: [PATCH 032/119] Add models in the API --- apps/api/urls.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/api/urls.py b/apps/api/urls.py index 8e0f6415..9bbe1978 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -3,6 +3,7 @@ from django.conf.urls import url, include from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType from django_filters.rest_framework import DjangoFilterBackend from rest_framework import routers, serializers, viewsets from rest_framework.filters import SearchFilter @@ -26,6 +27,16 @@ class UserSerializer(serializers.ModelSerializer): 'user_permissions', ) +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(viewsets.ModelViewSet): """ @@ -40,9 +51,20 @@ class UserViewSet(viewsets.ModelViewSet): search_fields = ['$username', '$first_name', '$last_name', ] +class ContentTypeViewSet(viewsets.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') From f082f8523ad78c30f7e44b142421cca858728a8f Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 11 Mar 2020 12:05:29 +0100 Subject: [PATCH 033/119] Update translations --- apps/note/models/notes.py | 2 +- apps/note/views.py | 2 +- locale/de/LC_MESSAGES/django.po | 161 ++++++++------ locale/fr/LC_MESSAGES/django.po | 216 +++++++++++-------- templates/base.html | 2 +- templates/member/club_form.html | 5 +- templates/member/club_list.html | 3 +- templates/member/signup.html | 6 +- templates/note/transactiontemplate_form.html | 3 +- 9 files changed, 234 insertions(+), 166 deletions(-) diff --git a/apps/note/models/notes.py b/apps/note/models/notes.py index 74cda3ea..c53c0140 100644 --- a/apps/note/models/notes.py +++ b/apps/note/models/notes.py @@ -231,7 +231,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: diff --git a/apps/note/views.py b/apps/note/views.py index ccba8c9f..7064370a 100644 --- a/apps/note/views.py +++ b/apps/note/views.py @@ -136,7 +136,7 @@ class ConsoView(LoginRequiredMixin, CreateView): context = super().get_context_data(**kwargs) context['transaction_templates'] = TransactionTemplate.objects.filter(display=True) \ .order_by('category') - context['title'] = _("Consommations") + context['title'] = _("Consumptions") # select2 compatibility context['no_cache'] = True diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index ce17f5de..6c60a9fe 100644 --- a/locale/de/LC_MESSAGES/django.po +++ b/locale/de/LC_MESSAGES/django.po @@ -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-11 11:44+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -24,7 +24,7 @@ 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/notes.py:188 apps/note/models/transactions.py:24 #: apps/note/models/transactions.py:44 templates/member/profile_detail.html:15 msgid "name" msgstr "" @@ -49,7 +49,7 @@ msgstr "" msgid "description" msgstr "" -#: apps/activity/models.py:54 apps/note/models/notes.py:163 +#: apps/activity/models.py:54 apps/note/models/notes.py:164 #: apps/note/models/transactions.py:62 msgid "type" msgstr "" @@ -90,7 +90,7 @@ msgstr "" 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 +114,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 "" @@ -188,7 +200,7 @@ msgid "" "members can renew their membership." msgstr "" -#: apps/member/models.py:93 apps/note/models/notes.py:138 +#: apps/member/models.py:93 apps/note/models/notes.py:139 msgid "club" msgstr "" @@ -237,7 +249,7 @@ msgstr "" msgid "Account #%(id)s: %(username)s" msgstr "" -#: apps/member/views.py:200 +#: apps/member/views.py:202 msgid "Alias successfully deleted" msgstr "" @@ -250,127 +262,127 @@ msgstr "" 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 +#: apps/note/forms.py:70 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:102 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:229 msgid "Alias is too long." msgstr "" -#: apps/note/models/notes.py:233 +#: apps/note/models/notes.py:234 msgid "An alias with a similar name already exists: {} " msgstr "" -#: apps/note/models/notes.py:242 +#: apps/note/models/notes.py:243 msgid "You can't delete your main alias." msgstr "" @@ -422,11 +434,11 @@ msgstr "" msgid "transactions" msgstr "" -#: apps/note/models/transactions.py:184 +#: apps/note/models/transactions.py:185 msgid "membership transaction" msgstr "" -#: apps/note/models/transactions.py:185 +#: apps/note/models/transactions.py:186 msgid "membership transactions" msgstr "" @@ -434,34 +446,54 @@ msgstr "" msgid "Transfer money from your account to one or others" msgstr "" -#: apps/note/views.py:138 +#: apps/note/views.py:139 msgid "Consommations" 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:63 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:70 +msgid "Consumptions" +msgstr "" + +#: templates/base.html:73 +msgid "Clubs" +msgstr "" + +#: templates/base.html:76 +msgid "Activities" +msgstr "" + +#: templates/base.html:79 +msgid "Button" +msgstr "" + +#: templates/base.html:82 templates/note/transaction_form.html:35 +msgid "Transfer" +msgstr "" + +#: templates/cas_server/base.html:7 msgid "Central Authentication Service" msgstr "" @@ -511,6 +543,15 @@ 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 +msgid "Submit" +msgstr "" + #: templates/member/club_detail.html:10 msgid "Membership starts on" msgstr "" @@ -583,10 +624,6 @@ msgstr "" msgid "Sign Up" msgstr "" -#: templates/note/transaction_form.html:35 -msgid "Transfer" -msgstr "" - #: templates/registration/logged_out.html:8 msgid "Thanks for spending some quality time with the Web site today." msgstr "" @@ -596,7 +633,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 +645,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 "" diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 3a8cfb79..05836a54 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -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-11 11:44+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -19,7 +19,7 @@ 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/notes.py:188 apps/note/models/transactions.py:24 #: apps/note/models/transactions.py:44 templates/member/profile_detail.html:15 msgid "name" msgstr "nom" @@ -44,7 +44,7 @@ msgstr "types d'activité" msgid "description" msgstr "description" -#: apps/activity/models.py:54 apps/note/models/notes.py:163 +#: apps/activity/models.py:54 apps/note/models/notes.py:164 #: apps/note/models/transactions.py:62 msgid "type" msgstr "type" @@ -85,15 +85,13 @@ msgstr "" 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 +106,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." @@ -193,10 +199,16 @@ 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:93 apps/note/models/notes.py:139 msgid "club" msgstr "club" +msgid "New club" +msgstr "Nouveau club" + +msgid "Clubs list" +msgstr "Liste des clubs" + #: apps/member/models.py:94 msgid "clubs" msgstr "clubs" @@ -242,9 +254,9 @@ 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 msgid "source" @@ -255,132 +267,128 @@ msgstr "source" 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 +#: apps/note/forms.py:70 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:102 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:229 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:234 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:243 msgid "You can't delete your main alias." msgstr "Vous ne pouvez pas supprimer votre alias principal." @@ -393,7 +401,6 @@ 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à." @@ -433,11 +440,11 @@ msgstr "transaction" msgid "transactions" msgstr "transactions" -#: apps/note/models/transactions.py:184 +#: apps/note/models/transactions.py:185 msgid "membership transaction" msgstr "transaction d'adhésion" -#: apps/note/models/transactions.py:185 +#: apps/note/models/transactions.py:186 msgid "membership transactions" msgstr "transactions d'adhésion" @@ -445,34 +452,53 @@ msgstr "transactions d'adhésion" 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:138 -msgid "Consommations" -msgstr "transactions" - -#: 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:63 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:70 +msgid "Consumptions" +msgstr "Consommations" + +#: templates/base.html:73 +msgid "Clubs" +msgstr "Clubs" + +#: templates/base.html:76 +msgid "Activities" +msgstr "Activités" + +#: templates/base.html:79 +msgid "Buttons" +msgstr "Boutons" + +msgid "Buttons list" +msgstr "Liste des boutons" + +#: templates/base.html:82 templates/note/transaction_form.html:35 +msgid "Transfer" +msgstr "Virement" + +#: templates/cas_server/base.html:7 msgid "Central Authentication Service" msgstr "" @@ -510,11 +536,11 @@ msgstr "" #: templates/cas_server/login.html:11 msgid "" -"If you don't have any Note Kfet account, please follow this link to sign up." +"If you don't have any Note Kfet account, please follow this link to sign up." msgstr "" -"Si vous n'avez pas de compte Note Kfet, veuillez suivre ce lien pour vous inscrire." +"Si vous n'avez pas de compte Note Kfet, veuillez suivre ce lien pour vous inscrire." #: templates/cas_server/login.html:17 msgid "Login" @@ -524,6 +550,15 @@ 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 +msgid "Submit" +msgstr "" + #: templates/member/club_detail.html:10 msgid "Membership starts on" msgstr "L'adhésion commence le" @@ -557,10 +592,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 +616,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 +627,10 @@ msgstr "Voir mes adhésions" msgid "Save Changes" msgstr "Sauvegarder les changements" +#: templates/member/signup.html:8 #: templates/member/signup.html:14 -msgid "Sign Up" -msgstr "" - -#: templates/note/transaction_form.html:35 -msgid "Transfer" -msgstr "Virement" +msgid "Sign up" +msgstr "Inscription" #: templates/registration/logged_out.html:8 msgid "Thanks for spending some quality time with the Web site today." @@ -613,7 +641,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 +653,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 "" diff --git a/templates/base.html b/templates/base.html index 887bc970..94810b87 100644 --- a/templates/base.html +++ b/templates/base.html @@ -76,7 +76,7 @@ SPDX-License-Identifier: GPL-3.0-or-later {% trans 'Activities' %}
{# Regroup buttons under categories #} - {% regroup transaction_templates by template_type as template_types %} + {% regroup transaction_templates by category as categories %}
{# Tabs for button categories #}