1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-06-20 17:41:55 +02:00

Merge branch 'consos' into rights

# Conflicts:
#	apps/logs/signals.py
#	note_kfet/settings/base.py
This commit is contained in:
Yohann D'ANELLO
2020-03-17 21:11:14 +01:00
81 changed files with 2850 additions and 818 deletions

View File

@ -11,7 +11,7 @@ class ActivityAdmin(admin.ModelAdmin):
Admin customisation for Activity
"""
list_display = ('name', 'activity_type', 'organizer')
list_filter = ('activity_type', )
list_filter = ('activity_type',)
search_fields = ['name', 'organizer__name']
# Organize activities by start date

View File

@ -11,6 +11,7 @@ class ActivityTypeSerializer(serializers.ModelSerializer):
REST API Serializer for Activity types.
The djangorestframework plugin will analyse the model `ActivityType` and parse all fields in the API.
"""
class Meta:
model = ActivityType
fields = '__all__'
@ -21,6 +22,7 @@ class ActivitySerializer(serializers.ModelSerializer):
REST API Serializer for Activities.
The djangorestframework plugin will analyse the model `Activity` and parse all fields in the API.
"""
class Meta:
model = Activity
fields = '__all__'
@ -31,6 +33,7 @@ class GuestSerializer(serializers.ModelSerializer):
REST API Serializer for Guests.
The djangorestframework plugin will analyse the model `Guest` and parse all fields in the API.
"""
class Meta:
model = Guest
fields = '__all__'

View File

@ -1,10 +1,11 @@
# 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 ..models import ActivityType, Activity, Guest
from .serializers import ActivityTypeSerializer, ActivitySerializer, GuestSerializer
from ..models import ActivityType, Activity, Guest
class ActivityTypeViewSet(viewsets.ModelViewSet):
@ -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', ]

View File

@ -3,10 +3,14 @@
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
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):
@ -14,6 +18,7 @@ class UserSerializer(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 = User
exclude = (
@ -23,6 +28,17 @@ class UserSerializer(serializers.ModelSerializer):
)
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):
"""
REST API View set.
@ -31,15 +47,30 @@ 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', ]
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')
register_note_urls(router, 'note')
register_logs_urls(router, 'logs')
app_name = 'api'

View File

View File

@ -0,0 +1,19 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from rest_framework import serializers
from ..models import Changelog
class ChangelogSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Changelog types.
The djangorestframework plugin will analyse the model `Changelog` and parse all fields in the API.
"""
class Meta:
model = Changelog
fields = '__all__'
# noinspection PyProtectedMember
read_only_fields = [f.name for f in model._meta.get_fields()] # Changelogs are read-only protected

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

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

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

@ -0,0 +1,23 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import viewsets
from rest_framework.filters import OrderingFilter
from .serializers import ChangelogSerializer
from ..models import Changelog
class ChangelogViewSet(viewsets.ReadOnlyModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer,
then render it on /api/logs/
"""
queryset = Changelog.objects.all()
serializer_class = ChangelogSerializer
filter_backends = [DjangoFilterBackend, OrderingFilter]
filterset_fields = ['model', 'action', "instance_pk", 'user', 'ip', ]
ordering_fields = ['timestamp', ]
ordering = ['-timestamp', ]

View File

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

55
apps/logs/middlewares.py Normal file
View File

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

View File

@ -1,16 +1,16 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
class Changelog(models.Model):
"""
Store each modification on the database (except sessions and logging),
Store each modification in the database (except sessions and logging),
including creating, editing and deleting models.
"""
@ -56,6 +56,12 @@ class Changelog(models.Model):
max_length=16,
null=False,
blank=False,
choices=[
('create', _('create')),
('edit', _('edit')),
('delete', _('delete')),
],
default='edit',
verbose_name=_('action'),
)

View File

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

View File

@ -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 = [
]

View File

@ -18,9 +18,9 @@ class ProfileInline(admin.StackedInline):
class CustomUserAdmin(UserAdmin):
inlines = (ProfileInline, )
inlines = (ProfileInline,)
list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff')
list_select_related = ('profile', )
list_select_related = ('profile',)
form = ProfileForm
def get_inline_instances(self, request, obj=None):

View File

@ -11,6 +11,7 @@ class ProfileSerializer(serializers.ModelSerializer):
REST API Serializer for Profiles.
The djangorestframework plugin will analyse the model `Profile` and parse all fields in the API.
"""
class Meta:
model = Profile
fields = '__all__'
@ -21,6 +22,7 @@ class ClubSerializer(serializers.ModelSerializer):
REST API Serializer for Clubs.
The djangorestframework plugin will analyse the model `Club` and parse all fields in the API.
"""
class Meta:
model = Club
fields = '__all__'
@ -31,6 +33,7 @@ class RoleSerializer(serializers.ModelSerializer):
REST API Serializer for Roles.
The djangorestframework plugin will analyse the model `Role` and parse all fields in the API.
"""
class Meta:
model = Role
fields = '__all__'
@ -41,6 +44,7 @@ class MembershipSerializer(serializers.ModelSerializer):
REST API Serializer for Memberships.
The djangorestframework plugin will analyse the model `Memberships` and parse all fields in the API.
"""
class Meta:
model = Membership
fields = '__all__'

View File

@ -2,9 +2,10 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from rest_framework import viewsets
from rest_framework.filters import SearchFilter
from ..models import Profile, Club, Role, Membership
from .serializers import ProfileSerializer, ClubSerializer, RoleSerializer, MembershipSerializer
from ..models import Profile, Club, Role, Membership
class ProfileViewSet(viewsets.ModelViewSet):
@ -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):

View File

@ -1,11 +1,11 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django_filters import FilterSet, CharFilter
from django.contrib.auth.models import User
from django.db.models import CharField
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Submit
from django.contrib.auth.models import User
from django.db.models import CharField
from django_filters import FilterSet, CharFilter
class UserFilter(FilterSet):

View File

@ -1,23 +1,22 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from crispy_forms.bootstrap import Div
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout
from dal import autocomplete
from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User
from django import forms
from .models import Profile, Club, Membership
from crispy_forms.helper import FormHelper
from crispy_forms.bootstrap import Div
from crispy_forms.layout import Layout
class SignUpForm(UserCreationForm):
def __init__(self,*args,**kwargs):
super().__init__(*args,**kwargs)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['username'].widget.attrs.pop("autofocus", None)
self.fields['first_name'].widget.attrs.update({"autofocus":"autofocus"})
self.fields['first_name'].widget.attrs.update({"autofocus": "autofocus"})
class Meta:
model = User
@ -28,6 +27,7 @@ class ProfileForm(forms.ModelForm):
"""
A form for the extras field provided by the :model:`member.Profile` model.
"""
class Meta:
model = Profile
fields = '__all__'
@ -42,7 +42,7 @@ class ClubForm(forms.ModelForm):
class AddMembersForm(forms.Form):
class Meta:
fields = ('', )
fields = ('',)
class MembershipForm(forms.ModelForm):
@ -54,13 +54,13 @@ class MembershipForm(forms.ModelForm):
# et récupère les noms d'utilisateur valides
widgets = {
'user':
autocomplete.ModelSelect2(
url='member:user_autocomplete',
attrs={
'data-placeholder': 'Nom ...',
'data-minimum-input-length': 1,
},
),
autocomplete.ModelSelect2(
url='member:user_autocomplete',
attrs={
'data-placeholder': 'Nom ...',
'data-minimum-input-length': 1,
},
),
}

View File

@ -5,8 +5,8 @@ import datetime
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
class Profile(models.Model):
@ -48,9 +48,10 @@ class Profile(models.Model):
class Meta:
verbose_name = _('user profile')
verbose_name_plural = _('user profile')
indexes = [models.Index(fields=['user'])]
def get_absolute_url(self):
return reverse('user_detail', args=(self.pk, ))
return reverse('user_detail', args=(self.pk,))
@ -100,7 +101,7 @@ class Club(models.Model):
return self.name
def get_absolute_url(self):
return reverse_lazy('member:club_detail', args=(self.pk, ))
return reverse_lazy('member:club_detail', args=(self.pk,))
class Role(models.Model):
@ -161,7 +162,7 @@ class Membership(models.Model):
class Meta:
verbose_name = _('membership')
verbose_name_plural = _('memberships')
indexes = [models.Index(fields=['user'])]
class RolePermissions(models.Model):
"""

View File

@ -1,6 +1,7 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
def save_user_profile(instance, created, raw, **_kwargs):
"""
Hook to create and save a profile when an user is updated if it is not registered with the signup form

View File

@ -1,33 +1,33 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import redirect
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, UpdateView, TemplateView,DeleteView
from django.views.generic.edit import FormMixin
from django.contrib.auth.models import User
from django.contrib import messages
from django.urls import reverse_lazy
from django.http import HttpResponseRedirect
from django.db.models import Q
from django.core.exceptions import ValidationError
from django.conf import settings
from django_tables2.views import SingleTableView
from rest_framework.authtoken.models import Token
from dal import autocomplete
from PIL import Image
import io
from PIL import Image
from dal import autocomplete
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.http import HttpResponseRedirect
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, UpdateView, TemplateView, DeleteView
from django.views.generic.edit import FormMixin
from django_tables2.views import SingleTableView
from rest_framework.authtoken.models import Token
from note.forms import AliasForm, ImageForm
from note.models import Alias, NoteUser
from note.models.transactions import Transaction
from note.tables import HistoryTable, AliasTable
from note.forms import AliasForm, ImageForm
from .models import Profile, Club, Membership
from .forms import SignUpForm, ProfileForm, ClubForm, MembershipForm, MemberFormSet, FormSetHelper
from .tables import ClubTable, UserTable
from .filters import UserFilter, UserFilterFormHelper
from .forms import SignUpForm, ProfileForm, ClubForm, MembershipForm, MemberFormSet, FormSetHelper
from .models import Club, Membership
from .tables import ClubTable, UserTable
class UserCreateView(CreateView):
@ -49,10 +49,10 @@ class UserCreateView(CreateView):
def form_valid(self, form):
profile_form = ProfileForm(self.request.POST)
if form.is_valid() and profile_form.is_valid():
user = form.save()
profile = profile_form.save(commit=False)
profile.user = user
profile.save()
user = form.save(commit=False)
user.profile = profile_form.save(commit=False)
user.save()
user.profile.save()
return super().form_valid(form)
@ -109,7 +109,7 @@ class UserUpdateView(LoginRequiredMixin, UpdateView):
return reverse_lazy('member:user_detail',
kwargs={'pk': kwargs['id']})
else:
return reverse_lazy('member:user_detail', args=(self.object.id, ))
return reverse_lazy('member:user_detail', args=(self.object.id,))
class UserDetailView(LoginRequiredMixin, DetailView):
@ -124,7 +124,7 @@ class UserDetailView(LoginRequiredMixin, DetailView):
context = super().get_context_data(**kwargs)
user = context['user_object']
history_list = \
Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note))
Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note)).order_by("-id")
context['history_list'] = HistoryTable(history_list)
club_list = \
Membership.objects.all().filter(user=user).only("club")
@ -157,13 +157,14 @@ class UserListView(LoginRequiredMixin, SingleTableView):
context["filter"] = self.filter
return context
class AliasView(LoginRequiredMixin,FormMixin,DetailView):
class AliasView(LoginRequiredMixin, FormMixin, DetailView):
model = User
template_name = 'member/profile_alias.html'
context_object_name = 'user_object'
form_class = AliasForm
def get_context_data(self,**kwargs):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
note = context['user_object'].note
context["aliases"] = AliasTable(note.alias_set.all())
@ -172,7 +173,7 @@ class AliasView(LoginRequiredMixin,FormMixin,DetailView):
def get_success_url(self):
return reverse_lazy('member:user_alias', kwargs={'pk': self.object.id})
def post(self,request,*args,**kwargs):
def post(self, request, *args, **kwargs):
self.object = self.get_object()
form = self.get_form()
if form.is_valid():
@ -186,42 +187,45 @@ class AliasView(LoginRequiredMixin,FormMixin,DetailView):
alias.save()
return super().form_valid(form)
class DeleteAliasView(LoginRequiredMixin, DeleteView):
model = Alias
def delete(self,request,*args,**kwargs):
def delete(self, request, *args, **kwargs):
try:
self.object = self.get_object()
self.object.delete()
except ValidationError as e:
# TODO: pass message to redirected view.
messages.error(self.request,str(e))
messages.error(self.request, str(e))
else:
messages.success(self.request,_("Alias successfully deleted"))
messages.success(self.request, _("Alias successfully deleted"))
return HttpResponseRedirect(self.get_success_url())
def get_success_url(self):
print(self.request)
return reverse_lazy('member:user_alias',kwargs={'pk':self.object.note.user.pk})
return reverse_lazy('member:user_alias', kwargs={'pk': self.object.note.user.pk})
def get(self, request, *args, **kwargs):
return self.post(request, *args, **kwargs)
class ProfilePictureUpdateView(LoginRequiredMixin, FormMixin, DetailView):
model = User
template_name = 'member/profile_picture_update.html'
context_object_name = 'user_object'
form_class = ImageForm
def get_context_data(self,*args,**kwargs):
context = super().get_context_data(*args,**kwargs)
context['form'] = self.form_class(self.request.POST,self.request.FILES)
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['form'] = self.form_class(self.request.POST, self.request.FILES)
return context
def get_success_url(self):
return reverse_lazy('member:user_detail', kwargs={'pk': self.object.id})
def post(self,request,*args,**kwargs):
form = self.get_form()
def post(self, request, *args, **kwargs):
form = self.get_form()
self.object = self.get_object()
if form.is_valid():
return self.form_valid(form)
@ -230,7 +234,7 @@ class ProfilePictureUpdateView(LoginRequiredMixin, FormMixin, DetailView):
print(form)
return self.form_invalid(form)
def form_valid(self,form):
def form_valid(self, form):
image_field = form.cleaned_data['image']
x = form.cleaned_data['x']
y = form.cleaned_data['y']
@ -238,23 +242,24 @@ class ProfilePictureUpdateView(LoginRequiredMixin, FormMixin, DetailView):
h = form.cleaned_data['height']
# image crop and resize
image_file = io.BytesIO(image_field.read())
ext = image_field.name.split('.')[-1]
# ext = image_field.name.split('.')[-1].lower()
# TODO: support GIF format
image = Image.open(image_file)
image = image.crop((x, y, x+w, y+h))
image = image.crop((x, y, x + w, y + h))
image_clean = image.resize((settings.PIC_WIDTH,
settings.PIC_RATIO*settings.PIC_WIDTH),
Image.ANTIALIAS)
settings.PIC_RATIO * settings.PIC_WIDTH),
Image.ANTIALIAS)
image_file = io.BytesIO()
image_clean.save(image_file,ext)
image_clean.save(image_file, "PNG")
image_field.file = image_file
# renaming
filename = "{}_pic.{}".format(self.object.note.pk, ext)
filename = "{}_pic.png".format(self.object.note.pk)
image_field.name = filename
self.object.note.display_image = image_field
self.object.note.save()
return super().form_valid(form)
class ManageAuthTokens(LoginRequiredMixin, TemplateView):
"""
Affiche le jeton d'authentification, et permet de le regénérer
@ -282,6 +287,7 @@ class UserAutocomplete(autocomplete.Select2QuerySetView):
"""
Auto complete users by usernames
"""
def get_queryset(self):
"""
Quand une personne cherche un utilisateur par pseudo, une requête est envoyée sur l'API dédiée à l'auto-complétion.
@ -294,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
@ -330,7 +336,7 @@ class ClubDetailView(LoginRequiredMixin, DetailView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
club = context["club"]
club_transactions = \
club_transactions = \
Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note))
context['history_list'] = HistoryTable(club_transactions)
club_member = \

View File

@ -47,11 +47,11 @@ class NoteClubAdmin(PolymorphicChildModelAdmin):
"""
Child for a club note, see NoteAdmin
"""
inlines = (AliasInlines, )
inlines = (AliasInlines,)
# We can't change club after creation or the balance
readonly_fields = ('club', 'balance')
search_fields = ('club', )
search_fields = ('club',)
def has_add_permission(self, request):
"""
@ -71,7 +71,7 @@ class NoteSpecialAdmin(PolymorphicChildModelAdmin):
"""
Child for a special note, see NoteAdmin
"""
readonly_fields = ('balance', )
readonly_fields = ('balance',)
@admin.register(NoteUser)
@ -79,7 +79,7 @@ class NoteUserAdmin(PolymorphicChildModelAdmin):
"""
Child for an user note, see NoteAdmin
"""
inlines = (AliasInlines, )
inlines = (AliasInlines,)
# We can't change user after creation or the balance
readonly_fields = ('user', 'balance')
@ -133,7 +133,7 @@ class TransactionAdmin(PolymorphicParentModelAdmin):
Else the amount of money would not be transferred
"""
if obj: # user is editing an existing object
return 'created_at', 'source', 'destination', 'quantity',\
return 'created_at', 'source', 'destination', 'quantity', \
'amount'
return []
@ -143,9 +143,9 @@ class TransactionTemplateAdmin(admin.ModelAdmin):
"""
Admin customisation for TransactionTemplate
"""
list_display = ('name', 'poly_destination', 'amount', 'category', 'display', )
list_display = ('name', 'poly_destination', 'amount', 'category', 'display',)
list_filter = ('category', 'display')
autocomplete_fields = ('destination', )
autocomplete_fields = ('destination',)
def poly_destination(self, obj):
"""
@ -161,5 +161,5 @@ class TemplateCategoryAdmin(admin.ModelAdmin):
"""
Admin customisation for TransactionTemplate
"""
list_display = ('name', )
list_filter = ('name', )
list_display = ('name',)
list_filter = ('name',)

View File

@ -5,7 +5,8 @@ from rest_framework import serializers
from rest_polymorphic.serializers import PolymorphicSerializer
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias
from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction
from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory, \
TemplateTransaction, SpecialTransaction
class NoteSerializer(serializers.ModelSerializer):
@ -13,15 +14,10 @@ class NoteSerializer(serializers.ModelSerializer):
REST API Serializer for Notes.
The djangorestframework plugin will analyse the model `Note` and parse all fields in the API.
"""
class Meta:
model = Note
fields = '__all__'
extra_kwargs = {
'url': {
'view_name': 'project-detail',
'lookup_field': 'pk'
},
}
class NoteClubSerializer(serializers.ModelSerializer):
@ -29,40 +25,60 @@ class NoteClubSerializer(serializers.ModelSerializer):
REST API Serializer for Club's notes.
The djangorestframework plugin will analyse the model `NoteClub` and parse all fields in the API.
"""
name = serializers.SerializerMethodField()
class Meta:
model = NoteClub
fields = '__all__'
def get_name(self, obj):
return str(obj)
class NoteSpecialSerializer(serializers.ModelSerializer):
"""
REST API Serializer for special notes.
The djangorestframework plugin will analyse the model `NoteSpecial` and parse all fields in the API.
"""
name = serializers.SerializerMethodField()
class Meta:
model = NoteSpecial
fields = '__all__'
def get_name(self, obj):
return str(obj)
class NoteUserSerializer(serializers.ModelSerializer):
"""
REST API Serializer for User's notes.
The djangorestframework plugin will analyse the model `NoteUser` and parse all fields in the API.
"""
name = serializers.SerializerMethodField()
class Meta:
model = NoteUser
fields = '__all__'
def get_name(self, obj):
return str(obj)
class AliasSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Aliases.
The djangorestframework plugin will analyse the model `Alias` and parse all fields in the API.
"""
note = serializers.SerializerMethodField()
class Meta:
model = Alias
fields = '__all__'
def get_note(self, alias):
return NotePolymorphicSerializer().to_representation(alias.note)
class NotePolymorphicSerializer(PolymorphicSerializer):
model_serializer_mapping = {
@ -73,11 +89,23 @@ 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.
The djangorestframework plugin will analyse the model `TransactionTemplate` and parse all fields in the API.
"""
class Meta:
model = TransactionTemplate
fields = '__all__'
@ -88,16 +116,49 @@ class TransactionSerializer(serializers.ModelSerializer):
REST API Serializer for Transactions.
The djangorestframework plugin will analyse the model `Transaction` and parse all fields in the API.
"""
class Meta:
model = Transaction
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.
The djangorestframework plugin will analyse the model `MembershipTransaction` and parse all fields in the API.
"""
class Meta:
model = MembershipTransaction
fields = '__all__'
class SpecialTransactionSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Special transactions.
The djangorestframework plugin will analyse the model `SpecialTransaction` and parse all fields in the API.
"""
class Meta:
model = SpecialTransaction
fields = '__all__'
class TransactionPolymorphicSerializer(PolymorphicSerializer):
model_serializer_mapping = {
Transaction: TransactionSerializer,
TemplateTransaction: TemplateTransactionSerializer,
MembershipTransaction: MembershipTransactionSerializer,
SpecialTransaction: SpecialTransactionSerializer,
}

View File

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

View File

@ -2,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 OrderingFilter, SearchFilter
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias
from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction
from .serializers import NoteSerializer, NotePolymorphicSerializer, NoteClubSerializer, NoteSpecialSerializer, \
NoteUserSerializer, AliasSerializer, \
TransactionTemplateSerializer, TransactionSerializer, MembershipTransactionSerializer
TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias
from ..models.transactions import TransactionTemplate, Transaction, TemplateCategory
class NoteViewSet(viewsets.ModelViewSet):
@ -59,6 +61,9 @@ class NotePolymorphicViewSet(viewsets.ModelViewSet):
"""
queryset = Note.objects.all()
serializer_class = NotePolymorphicSerializer
filter_backends = [SearchFilter, OrderingFilter]
search_fields = ['$alias__normalized_name', '$alias__name', '$polymorphic_ctype__model', ]
ordering_fields = ['alias__name', 'alias__normalized_name']
def get_queryset(self):
"""
@ -69,8 +74,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:
@ -80,12 +85,11 @@ class NotePolymorphicViewSet(viewsets.ModelViewSet):
elif "club" in types:
queryset = queryset.filter(polymorphic_ctype__model="noteclub")
elif "special" in types:
queryset = queryset.filter(
polymorphic_ctype__model="notespecial")
queryset = queryset.filter(polymorphic_ctype__model="notespecial")
else:
queryset = queryset.none()
return queryset
return queryset.distinct()
class AliasViewSet(viewsets.ModelViewSet):
@ -96,6 +100,9 @@ class AliasViewSet(viewsets.ModelViewSet):
"""
queryset = Alias.objects.all()
serializer_class = AliasSerializer
filter_backends = [SearchFilter, OrderingFilter]
search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
ordering_fields = ['name', 'normalized_name']
def get_queryset(self):
"""
@ -107,7 +114,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:
@ -131,6 +138,18 @@ 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
filter_backends = [SearchFilter]
search_fields = ['$name', ]
class TransactionTemplateViewSet(viewsets.ModelViewSet):
"""
REST API View set.
@ -139,6 +158,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):
@ -148,14 +169,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', ]

View File

@ -3,7 +3,7 @@
"model": "note.note",
"pk": 1,
"fields": {
"polymorphic_ctype": 22,
"polymorphic_ctype": 40,
"balance": 0,
"is_active": true,
"display_image": "",
@ -14,7 +14,7 @@
"model": "note.note",
"pk": 2,
"fields": {
"polymorphic_ctype": 22,
"polymorphic_ctype": 40,
"balance": 0,
"is_active": true,
"display_image": "",
@ -25,7 +25,7 @@
"model": "note.note",
"pk": 3,
"fields": {
"polymorphic_ctype": 22,
"polymorphic_ctype": 40,
"balance": 0,
"is_active": true,
"display_image": "",
@ -36,7 +36,7 @@
"model": "note.note",
"pk": 4,
"fields": {
"polymorphic_ctype": 22,
"polymorphic_ctype": 40,
"balance": 0,
"is_active": true,
"display_image": "",
@ -47,7 +47,7 @@
"model": "note.note",
"pk": 5,
"fields": {
"polymorphic_ctype": 21,
"polymorphic_ctype": 39,
"balance": 0,
"is_active": true,
"display_image": "",
@ -58,7 +58,7 @@
"model": "note.note",
"pk": 6,
"fields": {
"polymorphic_ctype": 21,
"polymorphic_ctype": 39,
"balance": 0,
"is_active": true,
"display_image": "",

View File

@ -3,31 +3,25 @@
from dal import autocomplete
from django import forms
from django.conf import settings
from django.utils.translation import gettext_lazy as _
import os
from .models import Alias
from .models import TransactionTemplate
from crispy_forms.helper import FormHelper
from crispy_forms.bootstrap import Div
from crispy_forms.layout import Layout, HTML
from .models import Transaction, TransactionTemplate, TemplateTransaction
from .models import Note, Alias
class AliasForm(forms.ModelForm):
class Meta:
model = Alias
fields = ("name",)
def __init__(self,*args,**kwargs):
super().__init__(*args,**kwargs)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["name"].label = False
self.fields["name"].widget.attrs={"placeholder":_('New Alias')}
self.fields["name"].widget.attrs = {"placeholder": _('New Alias')}
class ImageForm(forms.Form):
image = forms.ImageField(required = False,
image = forms.ImageField(required=False,
label=_('select an image'),
help_text=_('Maximal size: 2MB'))
x = forms.FloatField(widget=forms.HiddenInput())
@ -35,7 +29,7 @@ class ImageForm(forms.Form):
width = forms.FloatField(widget=forms.HiddenInput())
height = forms.FloatField(widget=forms.HiddenInput())
class TransactionTemplateForm(forms.ModelForm):
class Meta:
model = TransactionTemplate
@ -48,92 +42,11 @@ class TransactionTemplateForm(forms.ModelForm):
# forward=(forward.Const('TYPE', 'note_type') où TYPE est dans {user, club, special}
widgets = {
'destination':
autocomplete.ModelSelect2(
url='note:note_autocomplete',
attrs={
'data-placeholder': 'Note ...',
'data-minimum-input-length': 1,
},
),
}
class TransactionForm(forms.ModelForm):
def save(self, commit=True):
super().save(commit)
def clean(self):
"""
If the user has no right to transfer funds, then it will be the source of the transfer by default.
Transactions between a note and the same note are not authorized.
"""
cleaned_data = super().clean()
if not "source" in cleaned_data: # TODO Replace it with "if %user has no right to transfer funds"
cleaned_data["source"] = self.user.note
if cleaned_data["source"].pk == cleaned_data["destination"].pk:
self.add_error("destination", _("Source and destination must be different."))
return cleaned_data
class Meta:
model = Transaction
fields = (
'source',
'destination',
'reason',
'amount',
)
# Voir ci-dessus
widgets = {
'source':
autocomplete.ModelSelect2(
url='note:note_autocomplete',
attrs={
'data-placeholder': 'Note ...',
'data-minimum-input-length': 1,
},
),
'destination':
autocomplete.ModelSelect2(
url='note:note_autocomplete',
attrs={
'data-placeholder': 'Note ...',
'data-minimum-input-length': 1,
},
),
}
class ConsoForm(forms.ModelForm):
def save(self, commit=True):
button: TransactionTemplate = TransactionTemplate.objects.filter(
name=self.data['button']).get()
self.instance.destination = button.destination
self.instance.amount = button.amount
self.instance.reason = '{} ({})'.format(button.name, button.category)
self.instance.name = button.name
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,
},
),
autocomplete.ModelSelect2(
url='note:note_autocomplete',
attrs={
'data-placeholder': 'Note ...',
'data-minimum-input-length': 1,
},
),
}

View File

@ -9,6 +9,7 @@ from django.core.validators import RegexValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
from polymorphic.models import PolymorphicModel
"""
Defines each note types
"""
@ -27,7 +28,7 @@ class Note(PolymorphicModel):
help_text=_('in centimes, money credited for this instance'),
default=0,
)
last_negative= models.DateTimeField(
last_negative = models.DateTimeField(
verbose_name=_('last negative date'),
help_text=_('last time the balance was negative'),
null=True,
@ -98,7 +99,7 @@ class Note(PolymorphicModel):
# Alias exists, so check if it is linked to this note
if aliases.first().note != self:
raise ValidationError(_('This alias is already taken.'),
code="same_alias",)
code="same_alias", )
else:
# Alias does not exist yet, so check if it can exist
a = Alias(name=str(self))
@ -208,6 +209,10 @@ class Alias(models.Model):
class Meta:
verbose_name = _("alias")
verbose_name_plural = _("aliases")
indexes = [
models.Index(fields=['name']),
models.Index(fields=['normalized_name']),
]
def __str__(self):
return self.name
@ -230,13 +235,13 @@ 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)),
code="same_alias"
)
raise ValidationError(_('An alias with a similar name already exists: {} ').format(sim_alias),
code="same_alias"
)
except Alias.DoesNotExist:
pass
self.normalized_name = normalized_name
def delete(self, using=None, keep_parents=False):
if self.name == str(self.note):
raise ValidationError(_("You can't delete your main alias."),

View File

@ -2,12 +2,12 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.urls import reverse
from polymorphic.models import PolymorphicModel
from .notes import Note, NoteClub
from .notes import Note, NoteClub, NoteSpecial
"""
Defines transactions
@ -44,7 +44,7 @@ class TransactionTemplate(models.Model):
verbose_name=_('name'),
max_length=255,
unique=True,
error_messages={'unique':_("A template with this name already exist")},
error_messages={'unique': _("A template with this name already exist")},
)
destination = models.ForeignKey(
NoteClub,
@ -63,11 +63,12 @@ class TransactionTemplate(models.Model):
max_length=31,
)
display = models.BooleanField(
default = True,
default=True,
)
description = models.CharField(
verbose_name=_('description'),
max_length=255,
blank=True,
)
class Meta:
@ -75,7 +76,7 @@ class TransactionTemplate(models.Model):
verbose_name_plural = _("transaction templates")
def get_absolute_url(self):
return reverse('note:template_update', args=(self.pk, ))
return reverse('note:template_update', args=(self.pk,))
class Transaction(PolymorphicModel):
@ -106,7 +107,10 @@ class Transaction(PolymorphicModel):
verbose_name=_('quantity'),
default=1,
)
amount = models.PositiveIntegerField(verbose_name=_('amount'), )
amount = models.PositiveIntegerField(
verbose_name=_('amount'),
)
reason = models.CharField(
verbose_name=_('reason'),
max_length=255,
@ -119,6 +123,11 @@ class Transaction(PolymorphicModel):
class Meta:
verbose_name = _("transaction")
verbose_name_plural = _("transactions")
indexes = [
models.Index(fields=['created_at']),
models.Index(fields=['source']),
models.Index(fields=['destination']),
]
def save(self, *args, **kwargs):
"""
@ -127,6 +136,7 @@ class Transaction(PolymorphicModel):
if self.source.pk == self.destination.pk:
# When source == destination, no money is transfered
super().save(*args, **kwargs)
return
created = self.pk is None
@ -151,11 +161,14 @@ class Transaction(PolymorphicModel):
def total(self):
return self.amount * self.quantity
@property
def type(self):
return _('Transfer')
class TemplateTransaction(Transaction):
"""
Special type of :model:`note.Transaction` associated to a :model:`note.TransactionTemplate`.
"""
template = models.ForeignKey(
@ -168,6 +181,37 @@ class TemplateTransaction(Transaction):
on_delete=models.PROTECT,
)
@property
def type(self):
return _('Template')
class SpecialTransaction(Transaction):
"""
Special type of :model:`note.Transaction` associated to transactions with special notes
"""
last_name = models.CharField(
max_length=255,
verbose_name=_("name"),
)
first_name = models.CharField(
max_length=255,
verbose_name=_("first_name"),
)
bank = models.CharField(
max_length=255,
verbose_name=_("bank"),
blank=True,
)
@property
def type(self):
return _('Credit') if isinstance(self.source, NoteSpecial) else _("Debit")
class MembershipTransaction(Transaction):
"""
Special type of :model:`note.Transaction` associated to a :model:`member.Membership`.
@ -183,3 +227,7 @@ class MembershipTransaction(Transaction):
class Meta:
verbose_name = _("membership transaction")
verbose_name_plural = _("membership transactions")
@property
def type(self):
return _('membership transaction')

View File

@ -1,45 +1,77 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import html
import django_tables2 as tables
from django.db.models import F
from django_tables2.utils import A
from .models.transactions import Transaction
from django.utils.translation import gettext_lazy as _
from .models.notes import Alias
from .models.transactions import Transaction
from .templatetags.pretty_money import pretty_money
class HistoryTable(tables.Table):
class Meta:
attrs = {
'class':
'table table-condensed table-striped table-hover'
'table table-condensed table-striped table-hover'
}
model = Transaction
exclude = ("id", "polymorphic_ctype", )
template_name = 'django_tables2/bootstrap4.html'
sequence = ('...', 'total', 'valid')
sequence = ('...', 'type', 'total', 'valid', )
orderable = False
type = tables.Column()
total = tables.Column() # will use Transaction.total() !!
valid = tables.Column(attrs={"td": {"id": lambda record: "validate_" + str(record.id),
"class": lambda record: str(record.valid).lower() + ' validate',
"onclick": lambda record: 'de_validate(' + str(record.id) + ', '
+ str(record.valid).lower() + ')'}})
def order_total(self, queryset, is_descending):
# needed for rendering
queryset = queryset.annotate(total=F('amount') * F('quantity')) \
.order_by(('-' if is_descending else '') + 'total')
return (queryset, True)
return queryset, True
def render_amount(self, value):
return pretty_money(value)
def render_total(self, value):
return pretty_money(value)
def render_type(self, value):
return _(value)
# Django-tables escape strings. That's a wrong thing.
def render_reason(self, value):
return html.unescape(value)
def render_valid(self, value):
return "" if value else ""
class AliasTable(tables.Table):
class Meta:
attrs = {
'class':
'table table condensed table-striped table-hover'
'table table condensed table-striped table-hover'
}
model = Alias
fields =('name',)
fields = ('name',)
template_name = 'django_tables2/bootstrap4.html'
show_header = False
name = tables.Column(attrs={'td':{'class':'text-center'}})
name = tables.Column(attrs={'td': {'class': 'text-center'}})
delete = tables.LinkColumn('member:user_alias_delete',
args=[A('pk')],
attrs={
'td': {'class':'col-sm-2'},
'a': {'class': 'btn btn-danger'} },
text='delete',accessor='pk')
'td': {'class': 'col-sm-2'},
'a': {'class': 'btn btn-danger'}},
text='delete', accessor='pk')

View File

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

View File

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

View File

@ -3,56 +3,49 @@
from dal import autocomplete
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, ListView, UpdateView
from django_tables2 import SingleTableView
from .models import Transaction, TransactionTemplate, Alias, TemplateTransaction
from .forms import TransactionForm, TransactionTemplateForm, ConsoForm
from .forms import TransactionTemplateForm
from .models import Transaction, TransactionTemplate, Alias, TemplateTransaction, NoteSpecial
from .models.transactions import SpecialTransaction
from .tables import HistoryTable
class TransactionCreate(LoginRequiredMixin, CreateView):
class TransactionCreate(LoginRequiredMixin, SingleTableView):
"""
Show transfer page
TODO: If user have sufficient rights, they can transfer from an other note
"""
model = Transaction
form_class = TransactionForm
queryset = Transaction.objects.order_by("-id").all()[:50]
template_name = "note/transaction_form.html"
# Transaction history table
table_class = HistoryTable
table_pagination = {"per_page": 50}
def get_context_data(self, **kwargs):
"""
Add some context variables in template such as page title
"""
context = super().get_context_data(**kwargs)
context['title'] = _('Transfer money from your account '
'to one or others')
context['no_cache'] = True
context['title'] = _('Transfer money')
context['polymorphic_ctype'] = ContentType.objects.get_for_model(Transaction).pk
context['special_polymorphic_ctype'] = ContentType.objects.get_for_model(SpecialTransaction).pk
context['special_types'] = NoteSpecial.objects.order_by("special_type").all()
return context
def get_form(self, form_class=None):
"""
If the user has no right to transfer funds, then it won't have the choice of the source of the transfer.
"""
form = super().get_form(form_class)
if False: # TODO: fix it with "if %user has no right to transfer funds"
del form.fields['source']
form.user = self.request.user
return form
def get_success_url(self):
return reverse('note:transfer')
class NoteAutocomplete(autocomplete.Select2QuerySetView):
"""
Auto complete note by aliases
"""
def get_queryset(self):
"""
Quand une personne cherche un alias, une requête est envoyée sur l'API dédiée à l'auto-complétion.
@ -66,7 +59,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)
@ -120,31 +113,31 @@ class TransactionTemplateUpdateView(LoginRequiredMixin, UpdateView):
form_class = TransactionTemplateForm
class ConsoView(LoginRequiredMixin, CreateView):
class ConsoView(LoginRequiredMixin, SingleTableView):
"""
Consume
"""
model = TemplateTransaction
queryset = Transaction.objects.order_by("-id").all()[:50]
template_name = "note/conso_form.html"
form_class = ConsoForm
# Transaction history table
table_class = HistoryTable
table_pagination = {"per_page": 50}
def get_context_data(self, **kwargs):
"""
Add some context variables in template such as page title
"""
context = super().get_context_data(**kwargs)
context['transaction_templates'] = TransactionTemplate.objects.filter(display=True) \
.order_by('category')
context['title'] = _("Consommations")
from django.db.models import Count
buttons = TransactionTemplate.objects.filter(display=True) \
.annotate(clicks=Count('templatetransaction')).order_by('category__name', 'name')
context['transaction_templates'] = buttons
context['most_used'] = buttons.order_by('-clicks', 'name')[:10]
context['title'] = _("Consumptions")
context['polymorphic_ctype'] = ContentType.objects.get_for_model(TemplateTransaction).pk
# select2 compatibility
context['no_cache'] = True
return context
def get_success_url(self):
"""
When clicking a button, reload the same page
"""
return reverse('note:consos')