diff --git a/apps/activity/api/__init__.py b/apps/activity/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/activity/api/serializers.py b/apps/activity/api/serializers.py new file mode 100644 index 00000000..f7f949e7 --- /dev/null +++ b/apps/activity/api/serializers.py @@ -0,0 +1,35 @@ +# -*- mode: python; coding: utf-8 -*- +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from ..models import ActivityType, Activity, Guest +from rest_framework import serializers + +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__' + + +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__' + + +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__' diff --git a/apps/activity/api/urls.py b/apps/activity/api/urls.py new file mode 100644 index 00000000..56665730 --- /dev/null +++ b/apps/activity/api/urls.py @@ -0,0 +1,14 @@ +# -*- mode: python; coding: utf-8 -*- +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from .views import ActivityTypeViewSet, ActivityViewSet, GuestViewSet + + +def register_activity_urls(router, path): + """ + Configure router for Activity REST API. + """ + router.register(path + '/activity', ActivityViewSet) + router.register(path + '/type', ActivityTypeViewSet) + router.register(path + '/guest', GuestViewSet) diff --git a/apps/activity/api/views.py b/apps/activity/api/views.py new file mode 100644 index 00000000..4a0973e5 --- /dev/null +++ b/apps/activity/api/views.py @@ -0,0 +1,38 @@ +# -*- mode: python; coding: utf-8 -*- +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from rest_framework import viewsets + +from ..models import ActivityType, Activity, Guest +from .serializers import ActivityTypeSerializer, ActivitySerializer, GuestSerializer + + +class ActivityTypeViewSet(viewsets.ModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `ActivityType` objects, serialize it to JSON with the given serializer, + then render it on /api/activity/type/ + """ + queryset = ActivityType.objects.all() + serializer_class = ActivityTypeSerializer + + +class ActivityViewSet(viewsets.ModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `Activity` objects, serialize it to JSON with the given serializer, + then render it on /api/activity/activity/ + """ + queryset = Activity.objects.all() + serializer_class = ActivitySerializer + + +class GuestViewSet(viewsets.ModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `Guest` objects, serialize it to JSON with the given serializer, + then render it on /api/activity/guest/ + """ + queryset = Guest.objects.all() + serializer_class = GuestSerializer diff --git a/apps/api/urls.py b/apps/api/urls.py new file mode 100644 index 00000000..7ac56ca1 --- /dev/null +++ b/apps/api/urls.py @@ -0,0 +1,51 @@ +# -*- mode: python; coding: utf-8 -*- +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.conf.urls import url, include +from django.contrib.auth.models import User +from rest_framework import routers, serializers, viewsets +from rest_framework.authtoken import views as token_views + +from activity.api.urls import register_activity_urls +from member.api.urls import register_members_urls +from note.api.urls import register_note_urls + + +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 = ('password', 'groups', 'user_permissions',) + + +class UserViewSet(viewsets.ModelViewSet): + """ + 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 = User.objects.all() + serializer_class = UserSerializer + + +# Routers provide an easy way of automatically determining the URL conf. +# Register each app API router and user viewset +router = routers.DefaultRouter() +router.register('user', UserViewSet) +register_members_urls(router, 'members') +register_activity_urls(router, 'activity') +register_note_urls(router, 'note') + +app_name = 'api' + +# Wire up our API using automatic URL routing. +# Additionally, we include login URLs for the browsable API. +urlpatterns = [ + url('^', include(router.urls)), + url('^api-auth/', include('rest_framework.urls', namespace='rest_framework')), +] diff --git a/apps/member/api/__init__.py b/apps/member/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/member/api/serializers.py b/apps/member/api/serializers.py new file mode 100644 index 00000000..cf4420d5 --- /dev/null +++ b/apps/member/api/serializers.py @@ -0,0 +1,46 @@ +# -*- mode: python; coding: utf-8 -*- +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from ..models import Profile, Club, Role, Membership +from rest_framework import serializers + + +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__' + + +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__' + + +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__' + + +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__' diff --git a/apps/member/api/urls.py b/apps/member/api/urls.py new file mode 100644 index 00000000..f60465c0 --- /dev/null +++ b/apps/member/api/urls.py @@ -0,0 +1,15 @@ +# -*- mode: python; coding: utf-8 -*- +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from .views import ProfileViewSet, ClubViewSet, RoleViewSet, MembershipViewSet + + +def register_members_urls(router, path): + """ + Configure router for Member REST API. + """ + router.register(path + '/profile', ProfileViewSet) + router.register(path + '/club', ClubViewSet) + router.register(path + '/role', RoleViewSet) + router.register(path + '/membership', MembershipViewSet) diff --git a/apps/member/api/views.py b/apps/member/api/views.py new file mode 100644 index 00000000..36e8a33f --- /dev/null +++ b/apps/member/api/views.py @@ -0,0 +1,48 @@ +# -*- mode: python; coding: utf-8 -*- +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from rest_framework import viewsets + +from ..models import Profile, Club, Role, Membership +from .serializers import ProfileSerializer, ClubSerializer, RoleSerializer, MembershipSerializer + + +class ProfileViewSet(viewsets.ModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `Profile` objects, serialize it to JSON with the given serializer, + then render it on /api/members/profile/ + """ + queryset = Profile.objects.all() + serializer_class = ProfileSerializer + + +class ClubViewSet(viewsets.ModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `Club` objects, serialize it to JSON with the given serializer, + then render it on /api/members/club/ + """ + queryset = Club.objects.all() + serializer_class = ClubSerializer + + +class RoleViewSet(viewsets.ModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `Role` objects, serialize it to JSON with the given serializer, + then render it on /api/members/role/ + """ + queryset = Role.objects.all() + serializer_class = RoleSerializer + + +class MembershipViewSet(viewsets.ModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `Membership` objects, serialize it to JSON with the given serializer, + then render it on /api/members/membership/ + """ + queryset = Membership.objects.all() + serializer_class = MembershipSerializer diff --git a/apps/member/forms.py b/apps/member/forms.py index 59d3fec2..4d03764e 100644 --- a/apps/member/forms.py +++ b/apps/member/forms.py @@ -1,7 +1,7 @@ # -*- mode: python; coding: utf-8 -*- # Copyright (C) 2018-2019 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later - +from dal import autocomplete from django.contrib.auth.forms import UserChangeForm, UserCreationForm from django.contrib.auth.models import User from django import forms @@ -44,6 +44,17 @@ class MembershipForm(forms.ModelForm): class Meta: model = Membership fields = ('user','roles','date_start') + # 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 noms d'utilisateur valides + widgets = { + 'user': autocomplete.ModelSelect2(url='member:user_autocomplete', + attrs={ + 'data-placeholder': 'Nom ...', + 'data-minimum-input-length': 1, + }), + } + MemberFormSet = forms.modelformset_factory(Membership, form=MembershipForm, diff --git a/apps/member/urls.py b/apps/member/urls.py index 9bcc1095..d4e3e6af 100644 --- a/apps/member/urls.py +++ b/apps/member/urls.py @@ -18,4 +18,8 @@ urlpatterns = [ path('user/',views.UserListView.as_view(),name="user_list"), path('user/',views.UserDetailView.as_view(),name="user_detail"), path('user//update',views.UserUpdateView.as_view(),name="user_update_profile"), + path('manage-auth-token/', views.ManageAuthTokens.as_view(), name='auth_token'), + + # API for the user autocompleter + path('user/user-autocomplete',views.UserAutocomplete.as_view(),name="user_autocomplete"), ] diff --git a/apps/member/views.py b/apps/member/views.py index 90ea5ec3..be2d8d58 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -2,19 +2,19 @@ # Copyright (C) 2018-2019 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later - +from dal import autocomplete 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, ListView, DetailView, UpdateView -from django.http import HttpResponseRedirect -from django.contrib.auth.forms import UserCreationForm +from django.views.generic import CreateView, ListView, DetailView, UpdateView, RedirectView, TemplateView from django.contrib.auth.models import User from django.urls import reverse_lazy from django.db.models import Q from django_tables2.views import SingleTableView +from rest_framework.authtoken.models import Token - +from note.models import Alias, Note, NoteUser from .models import Profile, Club, Membership from .forms import SignUpForm, ProfileForm, ClubForm,MembershipForm, MemberFormSet,FormSetHelper from .tables import ClubTable,UserTable @@ -57,17 +57,43 @@ class UserUpdateView(LoginRequiredMixin,UpdateView): second_form = ProfileForm def get_context_data(self,**kwargs): context = super().get_context_data(**kwargs) - context["profile_form"] = self.second_form(instance=context['user'].profile) + context['user_modified'] = context['user'] + context['user'] = self.request.user + context["profile_form"] = self.second_form(instance=context['user_modified'].profile) return context + def get_form(self, form_class=None): + form = super().get_form(form_class) + if 'username' not in form.data: + return form + + new_username = form.data['username'] + + # Si l'utilisateur cherche à modifier son pseudo, le nouveau pseudo ne doit pas être proche d'un alias existant + note = NoteUser.objects.filter(alias__normalized_name=Alias.normalize(new_username)) + if note.exists() and note.get().user != self.request.user: + form.add_error('username', _("An alias with a similar name already exists.")) + + return form + + def form_valid(self, form): profile_form = ProfileForm(data=self.request.POST,instance=self.request.user.profile) if form.is_valid() and profile_form.is_valid(): - user = form.save() + new_username = form.data['username'] + alias = Alias.objects.filter(name=new_username) + # Si le nouveau pseudo n'est pas un de nos alias, on supprime éventuellement un alias similaire pour le remplacer + if not alias.exists(): + similar = Alias.objects.filter(normalized_name=Alias.normalize(new_username)) + if similar.exists(): + similar.delete() + + user = form.save(commit=False) profile = profile_form.save(commit=False) profile.user = user profile.save() + user.save() return super().form_valid(form) def get_success_url(self, **kwargs): @@ -115,6 +141,46 @@ class UserListView(LoginRequiredMixin,SingleTableView): return context +class ManageAuthTokens(LoginRequiredMixin, TemplateView): + """ + Affiche le jeton d'authentification, et permet de le regénérer + """ + model = Token + template_name = "member/manage_auth_tokens.html" + + def get(self, request, *args, **kwargs): + if 'regenerate' in request.GET and Token.objects.filter(user=request.user).exists(): + Token.objects.get(user=self.request.user).delete() + return redirect(reverse_lazy('member:auth_token') + "?show", permanent=True) + + return super().get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['token'] = Token.objects.get_or_create(user=self.request.user)[0] + return context + +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. + Cette fonction récupère la requête, et renvoie la liste filtrée des utilisateurs par pseudos. + """ + # Un utilisateur non connecté n'a accès à aucune information + if not self.request.user.is_authenticated: + return User.objects.none() + + qs = User.objects.all() + + if self.q: + qs = qs.filter(username__regex=self.q) + + return qs + ################################### ############## CLUB ############### ################################### diff --git a/apps/note/api/__init__.py b/apps/note/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/note/api/serializers.py b/apps/note/api/serializers.py new file mode 100644 index 00000000..afc3b419 --- /dev/null +++ b/apps/note/api/serializers.py @@ -0,0 +1,99 @@ +# -*- mode: python; coding: utf-8 -*- +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias +from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction +from rest_framework import serializers +from rest_polymorphic.serializers import PolymorphicSerializer + + +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): + """ + REST API Serializer for Club's notes. + The djangorestframework plugin will analyse the model `NoteClub` and parse all fields in the API. + """ + class Meta: + model = NoteClub + fields = '__all__' + + +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. + """ + class Meta: + model = NoteSpecial + fields = '__all__' + + +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. + """ + class Meta: + model = NoteUser + fields = '__all__' + + +class AliasSerializer(serializers.ModelSerializer): + """ + REST API Serializer for Aliases. + The djangorestframework plugin will analyse the model `Alias` and parse all fields in the API. + """ + class Meta: + model = Alias + fields = '__all__' + + +class NotePolymorphicSerializer(PolymorphicSerializer): + model_serializer_mapping = { + Note: NoteSerializer, + NoteUser: NoteUserSerializer, + NoteClub: NoteClubSerializer, + NoteSpecial: NoteSpecialSerializer + } + +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__' + + +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 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__' diff --git a/apps/note/api/urls.py b/apps/note/api/urls.py new file mode 100644 index 00000000..4ef14e28 --- /dev/null +++ b/apps/note/api/urls.py @@ -0,0 +1,18 @@ +# -*- mode: python; coding: utf-8 -*- +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from .views import NotePolymorphicViewSet, AliasViewSet, \ + TransactionViewSet, TransactionTemplateViewSet, MembershipTransactionViewSet + + +def register_note_urls(router, path): + """ + Configure router for Note REST API. + """ + router.register(path + '/note', NotePolymorphicViewSet) + router.register(path + '/alias', AliasViewSet) + + 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 new file mode 100644 index 00000000..37ca4e20 --- /dev/null +++ b/apps/note/api/views.py @@ -0,0 +1,155 @@ +# -*- mode: python; coding: utf-8 -*- +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.db.models import Q +from rest_framework import viewsets + +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 + + +class NoteViewSet(viewsets.ModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `Note` objects, serialize it to JSON with the given serializer, + then render it on /api/note/note/ + """ + queryset = Note.objects.all() + serializer_class = NoteSerializer + + +class NoteClubViewSet(viewsets.ModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `NoteClub` objects, serialize it to JSON with the given serializer, + then render it on /api/note/club/ + """ + queryset = NoteClub.objects.all() + serializer_class = NoteClubSerializer + + +class NoteSpecialViewSet(viewsets.ModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `NoteSpecial` objects, serialize it to JSON with the given serializer, + then render it on /api/note/special/ + """ + queryset = NoteSpecial.objects.all() + serializer_class = NoteSpecialSerializer + + +class NoteUserViewSet(viewsets.ModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `NoteUser` objects, serialize it to JSON with the given serializer, + then render it on /api/note/user/ + """ + queryset = NoteUser.objects.all() + serializer_class = NoteUserSerializer + + +class NotePolymorphicViewSet(viewsets.ModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `Note` objects (with polymorhism), serialize it to JSON with the given serializer, + then render it on /api/note/note/ + """ + queryset = Note.objects.all() + serializer_class = NotePolymorphicSerializer + + def get_queryset(self): + """ + Parse query and apply filters. + :return: The filtered set of requested notes + """ + queryset = Note.objects.all() + + alias = self.request.query_params.get("alias", ".*") + queryset = queryset.filter(Q(alias__name__regex=alias) | Q(alias__normalized_name__regex=alias.lower())) + + note_type = self.request.query_params.get("type", None) + if note_type: + l = str(note_type).lower() + if "user" in l: + queryset = queryset.filter(polymorphic_ctype__model="noteuser") + elif "club" in l: + queryset = queryset.filter(polymorphic_ctype__model="noteclub") + elif "special" in l: + queryset = queryset.filter(polymorphic_ctype__model="notespecial") + else: + queryset = queryset.none() + + return queryset + + +class AliasViewSet(viewsets.ModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `Alias` objects, serialize it to JSON with the given serializer, + then render it on /api/aliases/ + """ + queryset = Alias.objects.all() + serializer_class = AliasSerializer + + def get_queryset(self): + """ + Parse query and apply filters. + :return: The filtered set of requested aliases + """ + + queryset = Alias.objects.all() + + alias = self.request.query_params.get("alias", ".*") + queryset = queryset.filter(Q(name__regex=alias) | Q(normalized_name__regex=alias.lower())) + + note_id = self.request.query_params.get("note", None) + if note_id: + queryset = queryset.filter(id=note_id) + + note_type = self.request.query_params.get("type", None) + if note_type: + l = str(note_type).lower() + if "user" in l: + queryset = queryset.filter(note__polymorphic_ctype__model="noteuser") + elif "club" in l: + queryset = queryset.filter(note__polymorphic_ctype__model="noteclub") + elif "special" in l: + queryset = queryset.filter(note__polymorphic_ctype__model="notespecial") + else: + queryset = queryset.none() + + return queryset + + +class TransactionTemplateViewSet(viewsets.ModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `TransactionTemplate` objects, serialize it to JSON with the given serializer, + then render it on /api/note/transaction/template/ + """ + queryset = TransactionTemplate.objects.all() + serializer_class = TransactionTemplateSerializer + + +class TransactionViewSet(viewsets.ModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `Transaction` objects, serialize it to JSON with the given serializer, + then render it on /api/note/transaction/transaction/ + """ + queryset = Transaction.objects.all() + serializer_class = TransactionSerializer + + +class MembershipTransactionViewSet(viewsets.ModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `MembershipTransaction` objects, serialize it to JSON with the given serializer, + then render it on /api/note/transaction/membership/ + """ + queryset = MembershipTransaction.objects.all() + serializer_class = MembershipTransactionSerializer diff --git a/apps/note/forms.py b/apps/note/forms.py index d861345b..09818931 100644 --- a/apps/note/forms.py +++ b/apps/note/forms.py @@ -1,14 +1,54 @@ #!/usr/bin/env python +from dal import autocomplete, forward from django import forms -from .models import TransactionTemplate, Transaction +from .models import Transaction, TransactionTemplate class TransactionTemplateForm(forms.ModelForm): class Meta: model = TransactionTemplate fields ='__all__' + # Le champ de destination 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 valides + # Pour force le type d'une note, il faut rajouter le paramètre : + # 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): + self.instance.transaction_type = 'transfert' + + super().save(commit) + + 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 @@ -20,3 +60,14 @@ class ConsoForm(forms.ModelForm): class Meta: model = Transaction 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/models/notes.py b/apps/note/models/notes.py index e3ab7931..6a0c5ebe 100644 --- a/apps/note/models/notes.py +++ b/apps/note/models/notes.py @@ -85,7 +85,7 @@ class Note(PolymorphicModel): """ Verify alias (simulate save) """ - aliases = Alias.objects.filter(name=str(self)) + aliases = Alias.objects.filter(normalized_name=Alias.normalize(str(self))) if aliases.exists(): # Alias exists, so check if it is linked to this note if aliases.first().note != self: @@ -233,3 +233,8 @@ class Alias(models.Model): 'already exists.')) except Alias.DoesNotExist: pass + + def delete(self, using=None, keep_parents=False): + if self.name == str(self.note): + raise ValidationError(_("You can't delete your main alias.")) + return super().delete(using, keep_parents) diff --git a/apps/note/tables.py b/apps/note/tables.py index 4d4e9608..31cefe41 100644 --- a/apps/note/tables.py +++ b/apps/note/tables.py @@ -1,5 +1,7 @@ #!/usr/bin/env python import django_tables2 as tables +from django.db.models import F + from .models.transactions import Transaction diff --git a/apps/note/urls.py b/apps/note/urls.py index 59bb4672..7c19c425 100644 --- a/apps/note/urls.py +++ b/apps/note/urls.py @@ -5,6 +5,7 @@ from django.urls import path from . import views +from .models import Note app_name = 'note' urlpatterns = [ @@ -14,4 +15,7 @@ urlpatterns = [ path('buttons/',views.TransactionTemplateListView.as_view(),name='template_list'), path('consos//',views.ConsoView.as_view(),name='consos'), path('consos/',views.ConsoView.as_view(),name='consos'), + + # API for the note autocompleter + path('note-autocomplete/', views.NoteAutocomplete.as_view(model=Note),name='note_autocomplete'), ] diff --git a/apps/note/views.py b/apps/note/views.py index ce27832c..3414a6c0 100644 --- a/apps/note/views.py +++ b/apps/note/views.py @@ -2,13 +2,15 @@ # Copyright (C) 2018-2019 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +from dal import autocomplete from django.contrib.auth.mixins import LoginRequiredMixin +from django.db.models import Q from django.urls import reverse_lazy, reverse from django.utils.translation import gettext_lazy as _ from django.views.generic import CreateView, ListView, DetailView, UpdateView -from .models import Transaction,TransactionCategory,TransactionTemplate -from .forms import TransactionTemplateForm, ConsoForm +from .models import Note, Transaction, TransactionCategory, TransactionTemplate, Alias +from .forms import TransactionForm, TransactionTemplateForm, ConsoForm class TransactionCreate(LoginRequiredMixin, CreateView): """ @@ -17,7 +19,7 @@ class TransactionCreate(LoginRequiredMixin, CreateView): TODO: If user have sufficient rights, they can transfer from an other note """ model = Transaction - fields = ('destination', 'amount', 'reason') + form_class = TransactionForm def get_context_data(self, **kwargs): """ @@ -28,6 +30,77 @@ class TransactionCreate(LoginRequiredMixin, CreateView): 'to one or others') 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'] + + return form + + def form_valid(self, form): + """ + If the user has no right to transfer funds, then it will be the source of the transfer by default. + """ + if False: # TODO: fix it with "if %user has no right to transfer funds" + form.instance.source = self.request.user.note + + return super().form_valid(form) + + +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. + Cette fonction récupère la requête, et renvoie la liste filtrée des aliases. + """ + # Un utilisateur non connecté n'a accès à aucune information + if not self.request.user.is_authenticated: + return Alias.objects.none() + + qs = Alias.objects.all() + + # 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)))\ + .order_by('normalized_name').distinct() + + # Filtrage par type de note (user, club, special) + note_type = self.forwarded.get("note_type", None) + if note_type: + l = str(note_type).lower() + if "user" in l: + qs = qs.filter(note__polymorphic_ctype__model="noteuser") + elif "club" in l: + qs = qs.filter(note__polymorphic_ctype__model="noteclub") + elif "special" in l: + qs = qs.filter(note__polymorphic_ctype__model="notespecial") + else: + qs = qs.none() + + return qs + + def get_result_label(self, result): + # Gère l'affichage de l'alias dans la recherche + res = result.name + note_name = str(result.note) + if res != note_name: + res += " (aka. " + note_name + ")" + return res + + def get_result_value(self, result): + # Le résultat renvoyé doit être l'identifiant de la note, et non de l'alias + return str(result.note.pk) + + class TransactionTemplateCreateView(LoginRequiredMixin,CreateView): """ Create TransactionTemplate diff --git a/note_kfet/settings/base.py b/note_kfet/settings/base.py index af1f474b..9a526863 100644 --- a/note_kfet/settings/base.py +++ b/note_kfet/settings/base.py @@ -50,11 +50,18 @@ INSTALLED_APPS = [ 'django.contrib.sites', 'django.contrib.messages', 'django.contrib.staticfiles', + # API + 'rest_framework', + 'rest_framework.authtoken', + # Autocomplete + 'dal', + 'dal_select2', # Note apps 'activity', 'member', 'note', + 'api', ] LOGIN_REDIRECT_URL = '/note/transfer/' @@ -117,6 +124,18 @@ AUTHENTICATION_BACKENDS = ( 'guardian.backends.ObjectPermissionBackend', ) +REST_FRAMEWORK = { + # Use Django's standard `django.contrib.auth` permissions, + # or allow read-only access for unauthenticated users. + 'DEFAULT_PERMISSION_CLASSES': [ + # TODO Maybe replace it with our custom permissions system + 'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly' + ], + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + ] +} + ANONYMOUS_USER_NAME = None # Disable guardian anonymous user GUARDIAN_GET_CONTENT_TYPE = 'polymorphic.contrib.guardian.get_polymorphic_base_content_type' diff --git a/note_kfet/urls.py b/note_kfet/urls.py index 88bb6bb9..9fd63eef 100644 --- a/note_kfet/urls.py +++ b/note_kfet/urls.py @@ -19,4 +19,7 @@ urlpatterns = [ path('accounts/', include('django.contrib.auth.urls')), path('admin/doc/', include('django.contrib.admindocs.urls')), path('admin/', admin.site.urls), + + # Include Django REST API + path('api/', include('api.urls')), ] diff --git a/requirements.txt b/requirements.txt index 39b32fdf..d103764e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,11 +3,14 @@ chardet==3.0.4 defusedxml==0.6.0 Django==2.2.3 django-allauth==0.39.1 +django-autocomplete-light==3.3.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 django-reversion==3.0.3 django-tables2==2.1.0 docutils==0.14 diff --git a/templates/base.html b/templates/base.html index c6557d64..12b4cf79 100644 --- a/templates/base.html +++ b/templates/base.html @@ -31,6 +31,12 @@ SPDX-License-Identifier: GPL-3.0-or-later crossorigin="anonymous"> + + {# Si un formulaire requiert des données supplémentaires (notamment JS), les données sont chargées #} + {% if form.media %} + {{ form.media }} + {% endif %} + {% block extracss %}{% endblock %} diff --git a/templates/member/manage_auth_tokens.html b/templates/member/manage_auth_tokens.html new file mode 100644 index 00000000..0103fbbb --- /dev/null +++ b/templates/member/manage_auth_tokens.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% load i18n static pretty_money django_tables2 %} + +{% block content %} +
+

À quoi sert un jeton d'authentification ?

+ + Un jeton vous permet de vous connecter à l'API de la Note Kfet.
+ Il suffit pour cela d'ajouter en en-tête de vos requêtes Authorization: Token <TOKEN> + pour pouvoir vous identifier.

+ + Une documentation de l'API arrivera ultérieurement. +
+ +
+ {%trans 'Token' %} : + {% if 'show' in request.GET %} + {{ token.key }} (cacher) + {% else %} + caché (montrer) + {% endif %} +
+ {%trans 'Created' %} : {{ token.created }} +
+ +
+ Attention : regénérer le jeton va révoquer tout accès autorisé à l'API via ce jeton ! +
+ + + + +{% endblock %} diff --git a/templates/member/profile_detail.html b/templates/member/profile_detail.html index 53a0b9a0..dbf7075b 100644 --- a/templates/member/profile_detail.html +++ b/templates/member/profile_detail.html @@ -8,13 +8,13 @@
{% trans 'name'|capfirst %}
-
{{ object.user.name }}
+
{{ object.user.last_name }}
{% trans 'first name'|capfirst %}
{{ object.user.first_name }}
{% trans 'username'|capfirst %}
{{ object.user.username }}
Aliases
-
{{ object.user.note.aliases_set.all }}
+
{{ object.user.note.alias_set.all }}
{% trans 'section'|capfirst %}
{{ object.section }}
{% trans 'address'|capfirst %}
@@ -23,6 +23,9 @@
{{ object.user.note.balance | pretty_money }}
+ {% if object.user.pk == user.pk %} + {% trans 'Manage auth token' %} + {% endif %} {% trans 'Update Profile' %} {% trans 'Change password' %}