1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-06-21 01:48:21 +02:00

Merge branch 'master' into rights

# Conflicts:
#	note_kfet/settings/base.py
This commit is contained in:
Yohann D'ANELLO
2020-03-07 10:42:51 +01:00
429 changed files with 86067 additions and 920 deletions

View File

@ -1,5 +1,4 @@
# -*- mode: python; coding: utf-8 -*-
# Copyright (C) 2018-2019 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = 'activity.apps.ActivityConfig'

View File

@ -1,5 +1,4 @@
# -*- mode: python; coding: utf-8 -*-
# Copyright (C) 2018-2019 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
@ -12,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

View File

@ -0,0 +1,36 @@
# 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 ActivityType, Activity, Guest
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__'

13
apps/activity/api/urls.py Normal file
View File

@ -0,0 +1,13 @@
# 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)

View File

@ -0,0 +1,37 @@
# 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

View File

@ -1,5 +1,4 @@
# -*- mode: python; coding: utf-8 -*-
# Copyright (C) 2018-2019 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig

View File

@ -1,5 +1,4 @@
# -*- mode: python; coding: utf-8 -*-
# Copyright (C) 2018-2019 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
@ -9,7 +8,12 @@ from django.utils.translation import gettext_lazy as _
class ActivityType(models.Model):
"""
Type of Activity, (e.g "Pot", "Soirée Club") and associated properties
Type of Activity, (e.g "Pot", "Soirée Club") and associated properties.
Activity Type are used as a search field for Activity, and determine how
some rules about the activity:
- Can people be invited
- What is the entrance fee.
"""
name = models.CharField(
verbose_name=_('name'),
@ -32,7 +36,9 @@ class ActivityType(models.Model):
class Activity(models.Model):
"""
An IRL event organized by a club for others.
An IRL event organized by a club for other club.
By default the invited clubs should be the Club containing all the active accounts.
"""
name = models.CharField(
verbose_name=_('name'),
@ -73,7 +79,7 @@ class Activity(models.Model):
class Guest(models.Model):
"""
People who are not current members of any clubs, and invited by someone who is a current member.
People who are not current members of any clubs, and are invited by someone who is a current member.
"""
activity = models.ForeignKey(
Activity,
@ -94,6 +100,8 @@ class Guest(models.Model):
entry_transaction = models.ForeignKey(
'note.Transaction',
on_delete=models.PROTECT,
blank=True,
null=True,
)
class Meta:

4
apps/api/__init__.py Normal file
View File

@ -0,0 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = 'api.apps.APIConfig'

10
apps/api/apps.py Normal file
View File

@ -0,0 +1,10 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class APIConfig(AppConfig):
name = 'api'
verbose_name = _('API')

51
apps/api/urls.py Normal file
View File

@ -0,0 +1,51 @@
# 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 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')),
]

4
apps/logs/__init__.py Normal file
View File

@ -0,0 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = 'logs.apps.LogsConfig'

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

@ -0,0 +1,14 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class LogsConfig(AppConfig):
name = 'logs'
verbose_name = _('Logs')
def ready(self):
# noinspection PyUnresolvedReferences
import logs.signals

View File

71
apps/logs/models.py Normal file
View File

@ -0,0 +1,71 @@
# 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.core.exceptions import ValidationError
from django.db import models
class Changelog(models.Model):
"""
Store each modification on the database (except sessions and logging),
including creating, editing and deleting models.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
null=True,
verbose_name=_('user'),
)
ip = models.GenericIPAddressField(
null=True,
blank=True,
verbose_name=_("IP Address")
)
model = models.ForeignKey(
ContentType,
on_delete=models.PROTECT,
null=False,
blank=False,
verbose_name=_('model'),
)
instance_pk = models.CharField(
max_length=255,
null=False,
blank=False,
verbose_name=_('identifier'),
)
previous = models.TextField(
null=True,
verbose_name=_('previous data'),
)
data = models.TextField(
null=True,
verbose_name=_('new data'),
)
action = models.CharField( # create, edit or delete
max_length=16,
null=False,
blank=False,
verbose_name=_('action'),
)
timestamp = models.DateTimeField(
null=False,
blank=False,
auto_now_add=True,
name='timestamp',
verbose_name=_('timestamp'),
)
def delete(self, using=None, keep_parents=False):
raise ValidationError(_("Logs cannot be destroyed."))

119
apps/logs/signals.py Normal file
View File

@ -0,0 +1,119 @@
# 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 .models import Changelog
def get_request_in_signal(sender):
req = None
for entry in reversed(inspect.stack()):
try:
req = entry[0].f_locals['request']
# Check if there is a user
# noinspection PyStatementEffect
req.user
break
except:
pass
if not req:
print("WARNING: Attempt to save " + str(sender) + " with no user")
return req
def get_user_and_ip(sender):
req = get_request_in_signal(sender)
try:
user = req.user
if 'HTTP_X_FORWARDED_FOR' in req.META:
ip = req.META.get('HTTP_X_FORWARDED_FOR')
else:
ip = req.META.get('REMOTE_ADDR')
except:
user = None
ip = None
return user, ip
EXCLUDED = [
'admin.logentry',
'authtoken.token',
'cas_server.user',
'cas_server.userattributes',
'contenttypes.contenttype',
'logs.changelog',
'migrations.migration',
'note.noteuser',
'note.noteclub',
'note.notespecial',
'sessions.session',
'reversion.revision',
'reversion.version',
]
@receiver(pre_save)
def pre_save_object(sender, instance, **kwargs):
qs = sender.objects.filter(pk=instance.pk).all()
if qs.exists():
instance._previous = qs.get()
else:
instance._previous = None
@receiver(post_save)
def save_object(sender, instance, **kwargs):
# noinspection PyProtectedMember
if instance._meta.label_lower in EXCLUDED:
return
previous = instance._previous
user, ip = get_user_and_ip(sender)
if user is not None and instance._meta.label_lower == "auth.user" and previous:
# Don't save last login modifications
if instance.last_login != previous.last_login:
return
previous_json = serializers.serialize('json', [previous, ])[1:-1] if previous else None
instance_json = serializers.serialize('json', [instance, ])[1:-1]
if previous_json == instance_json:
# No modification
return
Changelog.objects.create(user=user,
ip=ip,
model=ContentType.objects.get_for_model(instance),
instance_pk=instance.pk,
previous=previous_json,
data=instance_json,
action=("edit" if previous else "create")
).save()
@receiver(post_delete)
def delete_object(sender, instance, **kwargs):
# noinspection PyProtectedMember
if instance._meta.label_lower in EXCLUDED:
return
user, ip = get_user_and_ip(sender)
instance_json = serializers.serialize('json', [instance, ])[1:-1]
Changelog.objects.create(user=user,
ip=ip,
model=ContentType.objects.get_for_model(instance),
instance_pk=instance.pk,
previous=instance_json,
data=None,
action="delete"
).save()

8
apps/logs/urls.py Normal file
View File

@ -0,0 +1,8 @@
# 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

@ -1,5 +1,4 @@
# -*- mode: python; coding: utf-8 -*-
# Copyright (C) 2018-2019 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = 'member.apps.MemberConfig'

View File

@ -1,5 +1,4 @@
# -*- mode: python; coding: utf-8 -*-
# Copyright (C) 2018-2019 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
@ -19,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

View File

@ -0,0 +1,46 @@
# 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 Profile, Club, Role, Membership
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__'

14
apps/member/api/urls.py Normal file
View File

@ -0,0 +1,14 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from .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)

47
apps/member/api/views.py Normal file
View File

@ -0,0 +1,47 @@
# 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

View File

@ -1,11 +1,23 @@
# -*- mode: python; coding: utf-8 -*-
# Copyright (C) 2018-2019 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
from django.conf import settings
from django.db.models.signals import post_save
from django.utils.translation import gettext_lazy as _
from .signals import save_user_profile
class MemberConfig(AppConfig):
name = 'member'
verbose_name = _('member')
def ready(self):
"""
Define app internal signals to interact with other apps
"""
post_save.connect(
save_user_profile,
sender=settings.AUTH_USER_MODEL,
)

33
apps/member/filters.py Normal file
View File

@ -0,0 +1,33 @@
# 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
class UserFilter(FilterSet):
class Meta:
model = User
fields = ['last_name', 'first_name', 'username', 'profile__section']
filter_overrides = {
CharField: {
'filter_class': CharFilter,
'extra': lambda f: {
'lookup_expr': 'icontains'
}
}
}
class UserFilterFormHelper(FormHelper):
form_method = 'GET'
layout = Layout(
'last_name',
'first_name',
'username',
'profile__section',
Submit('Submit', 'Apply Filter'),
)

View File

@ -0,0 +1,26 @@
[
{
"model": "member.club",
"pk": 1,
"fields": {
"name": "BDE",
"email": "tresorerie.bde@example.com",
"membership_fee": 5,
"membership_duration": "396 00:00:00",
"membership_start": "213 00:00:00",
"membership_end": "273 00:00:00"
}
},
{
"model": "member.club",
"pk": 2,
"fields": {
"name": "Kfet",
"email": "tresorerie.bde@example.com",
"membership_fee": 35,
"membership_duration": "396 00:00:00",
"membership_start": "213 00:00:00",
"membership_end": "273 00:00:00"
}
}
]

View File

@ -1,61 +1,88 @@
# -*- mode: python; coding: utf-8 -*-
# Copyright (C) 2018-2019 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
from dal import autocomplete
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 django.utils.translation import gettext_lazy as _
from crispy_forms.helper import FormHelper
from crispy_forms import layout, bootstrap
from crispy_forms.bootstrap import InlineField, FormActions, StrictButton, Div, Field
from crispy_forms.bootstrap import Div
from crispy_forms.layout import Layout
class SignUpForm(UserCreationForm):
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"})
class Meta:
model = User
fields = ['first_name', 'last_name', 'username', 'email']
class ProfileForm(forms.ModelForm):
"""
Forms pour la création d'un profile utilisateur.
A form for the extras field provided by the :model:`member.Profile` model.
"""
class Meta:
model = Profile
fields = '__all__'
exclude = ['user']
class ClubForm(forms.ModelForm):
class Meta:
model = Club
fields ='__all__'
fields = '__all__'
class AddMembersForm(forms.Form):
class Meta:
fields = ('',)
fields = ('', )
class MembershipForm(forms.ModelForm):
class Meta:
model = Membership
fields = ('user','roles','date_start')
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,
extra=2,
can_delete=True,
)
MemberFormSet = forms.modelformset_factory(Membership,
form=MembershipForm,
extra=2,
can_delete=True)
class FormSetHelper(FormHelper):
def __init__(self,*args,**kwargs):
super().__init__(*args,**kwargs)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.form_tag = False
self.form_method = 'POST'
self.form_class='form-inline'
self.form_class = 'form-inline'
# self.template = 'bootstrap/table_inline_formset.html'
self.layout = Layout(
Div(
Div('user',css_class='col-sm-2'),
Div('roles',css_class='col-sm-2'),
Div('date_start',css_class='col-sm-2'),
Div('user', css_class='col-sm-2'),
Div('roles', css_class='col-sm-2'),
Div('date_start', css_class='col-sm-2'),
css_class="row formset-row",
)
)
))

27
apps/member/hashers.py Normal file
View File

@ -0,0 +1,27 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import hashlib
from django.contrib.auth.hashers import PBKDF2PasswordHasher
from django.utils.crypto import constant_time_compare
class CustomNK15Hasher(PBKDF2PasswordHasher):
"""
Permet d'importer les mots de passe depuis la Note KFet 2015.
Si un hash de mot de passe est de la forme :
`custom_nk15$<NB>$<ENCODED>`
où <NB> est un entier quelconque (symbolisant normalement un nombre d'itérations)
et <ENCODED> le hash du mot de passe dans la Note Kfet 2015,
alors ce hasher va vérifier le mot de passe.
N'ayant pas la priorité (cf note_kfet/settings/base.py), le mot de passe sera
converti automatiquement avec l'algorithme PBKDF2.
"""
algorithm = "custom_nk15"
def verify(self, password, encoded):
if '|' in encoded:
salt, db_hashed_pass = encoded.split('$')[2].split('|')
return constant_time_compare(hashlib.sha256((salt + password).encode("utf-8")).hexdigest(), db_hashed_pass)
return super().verify(password, encoded)

View File

@ -1,13 +1,10 @@
# -*- mode: python; coding: utf-8 -*-
# Copyright (C) 2018-2019 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import datetime
from django.conf import settings
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from django.urls import reverse, reverse_lazy
@ -16,8 +13,9 @@ class Profile(models.Model):
"""
An user profile
We do not want to patch the Django Contrib Auth User class
We do not want to patch the Django Contrib :model:`auth.User`model;
so this model add an user profile with additional information.
"""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
@ -52,12 +50,14 @@ class Profile(models.Model):
verbose_name_plural = _('user profile')
def get_absolute_url(self):
return reverse('user_detail',args=(self.pk,))
return reverse('user_detail', args=(self.pk, ))
class Club(models.Model):
"""
A student club
A club is a group of people, whose membership is handle by their
:model:`member.Membership`, and gives access to right defined by a :model:`member.Role`.
"""
name = models.CharField(
verbose_name=_('name'),
@ -100,12 +100,15 @@ 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):
"""
Role that an user can have in a club
Role that an :model:`auth.User` can have in a :model:`member.Club`
TODO: Integrate the right management, and create some standard Roles at the
creation of the club.
"""
name = models.CharField(
verbose_name=_('name'),
@ -117,22 +120,26 @@ class Role(models.Model):
verbose_name = _('role')
verbose_name_plural = _('roles')
def __str__(self):
return str(self.name)
class Membership(models.Model):
"""
Register the membership of a user to a club, including roles and membership duration.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT
on_delete=models.PROTECT,
)
club = models.ForeignKey(
Club,
on_delete=models.PROTECT
on_delete=models.PROTECT,
)
roles = models.ForeignKey(
Role,
on_delete=models.PROTECT
on_delete=models.PROTECT,
)
date_start = models.DateField(
verbose_name=_('membership starts on'),

View File

@ -1,6 +1,15 @@
#!/usr/bin/env python
# -*- mode: python; coding: utf-8 -*-
# Copyright (C) 2018-2019 by BDE ENS Paris-Saclay
# 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
"""
if raw:
# When provisionning data, do not try to autocreate
return
if created:
from .models import Profile
Profile.objects.get_or_create(user=instance)
instance.profile.save()

View File

@ -1,13 +1,34 @@
#!/usr/bin/env python
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import django_tables2 as tables
from django.contrib.auth.models import User
from .models import Club
class ClubTable(tables.Table):
class Meta:
attrs = {'class':'table table-bordered table-condensed table-striped table-hover'}
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
model = Club
template_name = 'django_tables2/bootstrap.html'
fields= ('id','name','email')
row_attrs = {'class':'table-row',
'data-href': lambda record: record.pk }
template_name = 'django_tables2/bootstrap4.html'
fields = ('id', 'name', 'email')
row_attrs = {
'class': 'table-row',
'data-href': lambda record: record.pk
}
class UserTable(tables.Table):
section = tables.Column(accessor='profile.section')
solde = tables.Column(accessor='note.balance')
class Meta:
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
template_name = 'django_tables2/bootstrap4.html'
fields = ('last_name', 'first_name', 'username', 'email')
model = User

View File

@ -1,7 +1,4 @@
#!/usr/bin/env python
# -*- mode: python; coding: utf-8 -*-
# Copyright (C) 2018-2019 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path
@ -10,10 +7,18 @@ from . import views
app_name = 'member'
urlpatterns = [
path('signup/',views.UserCreateView.as_view(),name="signup"),
path('club/',views.ClubListView.as_view(),name="club_list"),
path('club/<int:pk>/',views.ClubDetailView.as_view(),name="club_detail"),
path('club/<int:pk>/add_member/',views.ClubAddMemberView.as_view(),name="club_add_member"),
path('club/create/',views.ClubCreateView.as_view(),name="club_create"),
path('user/<int:pk>',views.UserDetailView.as_view(),name="user_detail")
path('signup/', views.UserCreateView.as_view(), name="signup"),
path('club/', views.ClubListView.as_view(), name="club_list"),
path('club/<int:pk>/', views.ClubDetailView.as_view(), name="club_detail"),
path('club/<int:pk>/add_member/', views.ClubAddMemberView.as_view(), name="club_add_member"),
path('club/create/', views.ClubCreateView.as_view(), name="club_create"),
path('user/', views.UserListView.as_view(), name="user_list"),
path('user/<int:pk>', views.UserDetailView.as_view(), name="user_detail"),
path('user/<int:pk>/update', views.UserUpdateView.as_view(), name="user_update_profile"),
path('user/<int:pk>/update_pic', views.ProfilePictureUpdateView.as_view(), name="user_update_pic"),
path('user/<int:pk>/aliases', views.AliasView.as_view(), name="user_alias"),
path('user/aliases/delete/<int:pk>', views.DeleteAliasView.as_view(), name="user_alias_delete"),
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"),
]

View File

@ -1,88 +1,333 @@
#!/usr/bin/env python
# Copyright (C) 2018-2019 by BDE ENS Paris-Saclay
# 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, ListView, DetailView
from django.http import HttpResponseRedirect
from django.contrib.auth.forms import UserCreationForm
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 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 ProfileForm, ClubForm,MembershipForm, MemberFormSet,FormSetHelper
from .tables import ClubTable
from note.models.transactions import Transaction
from note.tables import HistoryTable
from .forms import SignUpForm, ProfileForm, ClubForm, MembershipForm, MemberFormSet, FormSetHelper
from .tables import ClubTable, UserTable
from .filters import UserFilter, UserFilterFormHelper
class UserCreateView(CreateView):
"""
Une vue pour inscrire un utilisateur et lui créer un profile
"""
form_class = ProfileForm
success_url = reverse_lazy('login')
template_name ='member/signup.html'
second_form = UserCreationForm
def get_context_data(self,**kwargs):
form_class = SignUpForm
success_url = reverse_lazy('login')
template_name = 'member/signup.html'
second_form = ProfileForm
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["user_form"] = self.second_form
context["profile_form"] = self.second_form()
return context
def form_valid(self, form):
user_form = UserCreationForm(self.request.POST)
if user_form.is_valid():
user = user_form.save()
user_profile = form.save(commit=False) # do not save to db
user_profile.user = user
user_profile.save()
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()
return super().form_valid(form)
class UserDetailView(LoginRequiredMixin,DetailView):
model = Profile
context_object_name = "profile"
def get_context_data(slef,**kwargs):
class UserUpdateView(LoginRequiredMixin, UpdateView):
model = User
fields = ['first_name', 'last_name', 'username', 'email']
template_name = 'member/profile_update.html'
context_object_name = 'user_object'
profile_form = ProfileForm
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user = context['profile'].user
context['profile_form'] = self.profile_form(instance=context['user_object'].profile)
context['title'] = _("Update 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.object:
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.object.profile,
)
if form.is_valid() and profile_form.is_valid():
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):
if kwargs:
return reverse_lazy('member:user_detail',
kwargs={'pk': kwargs['id']})
else:
return reverse_lazy('member:user_detail', args=(self.object.id, ))
class UserDetailView(LoginRequiredMixin, DetailView):
"""
Affiche les informations sur un utilisateur, sa note, ses clubs...
"""
model = User
context_object_name = "user_object"
template_name = "member/profile_detail.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user = context['user_object']
history_list = \
Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note))
context['history_list'] = HistoryTable(history_list)
club_list = \
Membership.objects.all().filter(user=user).only("club")
context['club_list'] = ClubTable(club_list)
context['title'] = _("Account #%(id)s: %(username)s") % {
'id': user.pk,
'username': user.username,
}
return context
class ClubCreateView(LoginRequiredMixin,CreateView):
class UserListView(LoginRequiredMixin, SingleTableView):
"""
Affiche la liste des utilisateurs, avec une fonction de recherche statique
"""
model = User
table_class = UserTable
template_name = 'member/user_list.html'
filter_class = UserFilter
formhelper_class = UserFilterFormHelper
def get_queryset(self, **kwargs):
qs = super().get_queryset()
self.filter = self.filter_class(self.request.GET, queryset=qs)
self.filter.form.helper = self.formhelper_class()
return self.filter.qs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["filter"] = self.filter
return context
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):
context = super().get_context_data(**kwargs)
note = context['user_object'].note
context["aliases"] = AliasTable(note.alias_set.all())
return context
def get_success_url(self):
return reverse_lazy('member:user_alias', kwargs={'pk': self.object.id})
def post(self,request,*args,**kwargs):
self.object = self.get_object()
form = self.get_form()
if form.is_valid():
return self.form_valid(form)
else:
return self.form_invalid(form)
def form_valid(self, form):
alias = form.save(commit=False)
alias.note = self.object.note
alias.save()
return super().form_valid(form)
class DeleteAliasView(LoginRequiredMixin, DeleteView):
model = Alias
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))
else:
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})
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)
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()
self.object = self.get_object()
if form.is_valid():
return self.form_valid(form)
else:
print('is_invalid')
print(form)
return self.form_invalid(form)
def form_valid(self,form):
image_field = form.cleaned_data['image']
x = form.cleaned_data['x']
y = form.cleaned_data['y']
w = form.cleaned_data['width']
h = form.cleaned_data['height']
# image crop and resize
image_file = io.BytesIO(image_field.read())
ext = image_field.name.split('.')[-1]
image = Image.open(image_file)
image = image.crop((x, y, x+w, y+h))
image_clean = image.resize((settings.PIC_WIDTH,
settings.PIC_RATIO*settings.PIC_WIDTH),
Image.ANTIALIAS)
image_file = io.BytesIO()
image_clean.save(image_file,ext)
image_field.file = image_file
# renaming
filename = "{}_pic.{}".format(self.object.note.pk, ext)
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
"""
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 #
# ******************************* #
class ClubCreateView(LoginRequiredMixin, CreateView):
"""
Create Club
"""
model = Club
form_class = ClubForm
def form_valid(self,form):
def form_valid(self, form):
return super().form_valid(form)
class ClubListView(LoginRequiredMixin,SingleTableView):
class ClubListView(LoginRequiredMixin, SingleTableView):
"""
List existing tables
List existing Clubs
"""
model = Club
table_class = ClubTable
class ClubDetailView(LoginRequiredMixin,DetailView):
model = Club
context_object_name="club"
def get_context_data(self,**kwargs):
class ClubDetailView(LoginRequiredMixin, DetailView):
model = Club
context_object_name = "club"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
club = context["club"]
club_transactions = \
@ -93,24 +338,31 @@ class ClubDetailView(LoginRequiredMixin,DetailView):
# TODO: consider only valid Membership
context['member_list'] = club_member
return context
class ClubAddMemberView(LoginRequiredMixin,CreateView):
class ClubAddMemberView(LoginRequiredMixin, CreateView):
model = Membership
form_class = MembershipForm
template_name = 'member/add_members.html'
def get_context_data(self,**kwargs):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['formset'] = MemberFormSet()
context['helper'] = FormSetHelper()
return context
def post(self,request,*args,**kwargs):
formset = MembershipFormset(request.POST)
if formset.is_valid():
return self.form_valid(formset)
else:
return self.form_invalid(formset)
def form_valid(self,formset):
context['no_cache'] = True
return context
def post(self, request, *args, **kwargs):
return
# TODO: Implement POST
# formset = MembershipFormset(request.POST)
# if formset.is_valid():
# return self.form_valid(formset)
# else:
# return self.form_invalid(formset)
def form_valid(self, formset):
formset.save()
return super().form_valid(formset)

View File

@ -1,5 +1,4 @@
# -*- mode: python; coding: utf-8 -*-
# Copyright (C) 2018-2019 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = 'note.apps.NoteConfig'

View File

@ -1,5 +1,4 @@
# -*- mode: python; coding: utf-8 -*-
# Copyright (C) 2018-2019 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
@ -8,7 +7,8 @@ from polymorphic.admin import PolymorphicChildModelAdmin, \
PolymorphicChildModelFilter, PolymorphicParentModelAdmin
from .models.notes import Alias, Note, NoteClub, NoteSpecial, NoteUser
from .models.transactions import Transaction, TransactionTemplate
from .models.transactions import Transaction, TemplateCategory, TransactionTemplate, \
TemplateTransaction, MembershipTransaction
class AliasInlines(admin.TabularInline):
@ -25,7 +25,10 @@ class NoteAdmin(PolymorphicParentModelAdmin):
Parent regrouping all note types as children
"""
child_models = (NoteClub, NoteSpecial, NoteUser)
list_filter = (PolymorphicChildModelFilter, 'is_active',)
list_filter = (
PolymorphicChildModelFilter,
'is_active',
)
# Use a polymorphic list
list_display = ('pretty', 'balance', 'is_active')
@ -44,11 +47,12 @@ 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):
"""
A club note should not be manually added
@ -67,7 +71,7 @@ class NoteSpecialAdmin(PolymorphicChildModelAdmin):
"""
Child for a special note, see NoteAdmin
"""
readonly_fields = ('balance',)
readonly_fields = ('balance', )
@admin.register(NoteUser)
@ -75,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')
@ -94,14 +98,18 @@ class NoteUserAdmin(PolymorphicChildModelAdmin):
@admin.register(Transaction)
class TransactionAdmin(admin.ModelAdmin):
class TransactionAdmin(PolymorphicParentModelAdmin):
"""
Admin customisation for Transaction
"""
child_models = (TemplateTransaction, MembershipTransaction)
list_display = ('created_at', 'poly_source', 'poly_destination',
'quantity', 'amount', 'transaction_type', 'valid')
list_filter = ('transaction_type', 'valid')
autocomplete_fields = ('source', 'destination',)
'quantity', 'amount', 'valid')
list_filter = ('valid',)
autocomplete_fields = (
'source',
'destination',
)
def poly_source(self, obj):
"""
@ -126,7 +134,7 @@ class TransactionAdmin(admin.ModelAdmin):
"""
if obj: # user is editing an existing object
return 'created_at', 'source', 'destination', 'quantity',\
'amount', 'transaction_type'
'amount'
return []
@ -135,9 +143,9 @@ class TransactionTemplateAdmin(admin.ModelAdmin):
"""
Admin customisation for TransactionTemplate
"""
list_display = ('name', 'poly_destination', 'amount', 'template_type')
list_filter = ('template_type',)
autocomplete_fields = ('destination',)
list_display = ('name', 'poly_destination', 'amount', 'category', 'display', )
list_filter = ('category', 'display')
autocomplete_fields = ('destination', )
def poly_destination(self, obj):
"""
@ -146,3 +154,12 @@ class TransactionTemplateAdmin(admin.ModelAdmin):
return str(obj.destination)
poly_destination.short_description = _('destination')
@admin.register(TemplateCategory)
class TemplateCategoryAdmin(admin.ModelAdmin):
"""
Admin customisation for TransactionTemplate
"""
list_display = ('name', )
list_filter = ('name', )

View File

View File

@ -0,0 +1,103 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
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
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__'

17
apps/note/api/urls.py Normal file
View File

@ -0,0 +1,17 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from .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)

161
apps/note/api/views.py Normal file
View File

@ -0,0 +1,161 @@
# 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:
types = str(note_type).lower()
if "user" in types:
queryset = queryset.filter(polymorphic_ctype__model="noteuser")
elif "club" in types:
queryset = queryset.filter(polymorphic_ctype__model="noteclub")
elif "special" in types:
queryset = queryset.filter(
polymorphic_ctype__model="notespecial")
else:
queryset = queryset.none()
return queryset
class AliasViewSet(viewsets.ModelViewSet):
"""
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:
types = str(note_type).lower()
if "user" in types:
queryset = queryset.filter(
note__polymorphic_ctype__model="noteuser")
elif "club" in types:
queryset = queryset.filter(
note__polymorphic_ctype__model="noteclub")
elif "special" in types:
queryset = queryset.filter(
note__polymorphic_ctype__model="notespecial")
else:
queryset = queryset.none()
return queryset
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

View File

@ -1,5 +1,4 @@
# -*- mode: python; coding: utf-8 -*-
# Copyright (C) 2018-2019 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
@ -20,9 +19,9 @@ class NoteConfig(AppConfig):
"""
post_save.connect(
signals.save_user_note,
sender=settings.AUTH_USER_MODEL
sender=settings.AUTH_USER_MODEL,
)
post_save.connect(
signals.save_club_note,
sender='member.Club'
sender='member.Club',
)

View File

@ -0,0 +1,220 @@
[
{
"model": "note.note",
"pk": 1,
"fields": {
"polymorphic_ctype": 22,
"balance": 0,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:02:48.778Z"
}
},
{
"model": "note.note",
"pk": 2,
"fields": {
"polymorphic_ctype": 22,
"balance": 0,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:06:39.546Z"
}
},
{
"model": "note.note",
"pk": 3,
"fields": {
"polymorphic_ctype": 22,
"balance": 0,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:06:43.049Z"
}
},
{
"model": "note.note",
"pk": 4,
"fields": {
"polymorphic_ctype": 22,
"balance": 0,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:06:50.996Z"
}
},
{
"model": "note.note",
"pk": 5,
"fields": {
"polymorphic_ctype": 21,
"balance": 0,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:09:38.615Z"
}
},
{
"model": "note.note",
"pk": 6,
"fields": {
"polymorphic_ctype": 21,
"balance": 0,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:16:14.753Z"
}
},
{
"model": "note.notespecial",
"pk": 1,
"fields": {
"special_type": "Esp\u00e8ces"
}
},
{
"model": "note.notespecial",
"pk": 2,
"fields": {
"special_type": "Carte bancaire"
}
},
{
"model": "note.notespecial",
"pk": 3,
"fields": {
"special_type": "Ch\u00e8que"
}
},
{
"model": "note.notespecial",
"pk": 4,
"fields": {
"special_type": "Virement bancaire"
}
},
{
"model": "note.noteclub",
"pk": 5,
"fields": {
"club": 1
}
},
{
"model": "note.noteclub",
"pk": 6,
"fields": {
"club": 2
}
},
{
"model": "note.alias",
"pk": 1,
"fields": {
"name": "Esp\u00e8ces",
"normalized_name": "especes",
"note": 1
}
},
{
"model": "note.alias",
"pk": 2,
"fields": {
"name": "Carte bancaire",
"normalized_name": "cartebancaire",
"note": 2
}
},
{
"model": "note.alias",
"pk": 3,
"fields": {
"name": "Ch\u00e8que",
"normalized_name": "cheque",
"note": 3
}
},
{
"model": "note.alias",
"pk": 4,
"fields": {
"name": "Virement bancaire",
"normalized_name": "virementbancaire",
"note": 4
}
},
{
"model": "note.alias",
"pk": 5,
"fields": {
"name": "BDE",
"normalized_name": "bde",
"note": 5
}
},
{
"model": "note.alias",
"pk": 6,
"fields": {
"name": "Kfet",
"normalized_name": "kfet",
"note": 6
}
},
{
"model": "note.templatecategory",
"pk": 1,
"fields": {
"name": "Soft"
}
},
{
"model": "note.templatecategory",
"pk": 2,
"fields": {
"name": "Pulls"
}
},
{
"model": "note.templatecategory",
"pk": 3,
"fields": {
"name": "Gala"
}
},
{
"model": "note.templatecategory",
"pk": 4,
"fields": {
"name": "Clubs"
}
},
{
"model": "note.templatecategory",
"pk": 5,
"fields": {
"name": "Bouffe"
}
},
{
"model": "note.templatecategory",
"pk": 6,
"fields": {
"name": "BDA"
}
},
{
"model": "note.templatecategory",
"pk": 7,
"fields": {
"name": "Autre"
}
},
{
"model": "note.templatecategory",
"pk": 8,
"fields": {
"name": "Alcool"
}
}
]

View File

@ -1,9 +1,139 @@
#!/usr/bin/env python
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from dal import autocomplete
from django import forms
from .models import TransactionTemplate
from django.conf import settings
from django.utils.translation import gettext_lazy as _
import os
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)
self.fields["name"].label = False
self.fields["name"].widget.attrs={"placeholder":_('New Alias')}
class ImageForm(forms.Form):
image = forms.ImageField(required = False,
label=_('select an image'),
help_text=_('Maximal size: 2MB'))
x = forms.FloatField(widget=forms.HiddenInput())
y = forms.FloatField(widget=forms.HiddenInput())
width = forms.FloatField(widget=forms.HiddenInput())
height = forms.FloatField(widget=forms.HiddenInput())
class TransactionTemplateForm(forms.ModelForm):
class Meta:
model = TransactionTemplate
fields ='__all__'
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):
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,
},
),
}

View File

@ -1,14 +1,14 @@
# -*- mode: python; coding: utf-8 -*-
# Copyright (C) 2018-2019 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from .notes import Alias, Note, NoteClub, NoteSpecial, NoteUser
from .transactions import MembershipTransaction, Transaction, \
TransactionTemplate
TemplateCategory, TransactionTemplate, TemplateTransaction
__all__ = [
# Notes
'Alias', 'Note', 'NoteClub', 'NoteSpecial', 'NoteUser',
# Transactions
'MembershipTransaction', 'Transaction', 'TransactionTemplate',
'MembershipTransaction', 'Transaction', 'TemplateCategory', 'TransactionTemplate',
'TemplateTransaction',
]

View File

@ -1,5 +1,4 @@
# -*- mode: python; coding: utf-8 -*-
# Copyright (C) 2018-2019 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import unicodedata
@ -10,7 +9,6 @@ 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
"""
@ -18,25 +16,37 @@ Defines each note types
class Note(PolymorphicModel):
"""
An model, use to add transactions capabilities
Gives transactions capabilities. Note is a Polymorphic Model, use as based
for the models :model:`note.NoteUser` and :model:`note.NoteClub`.
A Note principaly store the actual balance of someone/some club.
A Note can be searched find throught an :model:`note.Alias`
"""
balance = models.IntegerField(
verbose_name=_('account balance'),
help_text=_('in centimes, money credited for this instance'),
default=0,
)
last_negative= models.DateTimeField(
verbose_name=_('last negative date'),
help_text=_('last time the balance was negative'),
null=True,
blank=True,
)
is_active = models.BooleanField(
_('active'),
default=True,
help_text=_(
'Designates whether this note should be treated as active. '
'Unselect this instead of deleting notes.'
),
'Unselect this instead of deleting notes.'),
)
display_image = models.ImageField(
verbose_name=_('display image'),
max_length=255,
blank=True,
blank=False,
null=False,
upload_to='pic/',
default='pic/default.png'
)
created_at = models.DateTimeField(
verbose_name=_('created at'),
@ -63,7 +73,8 @@ class Note(PolymorphicModel):
if aliases.exists():
# Alias exists, so check if it is linked to this note
if aliases.first().note != self:
raise ValidationError(_('This alias is already taken.'))
raise ValidationError(_('This alias is already taken.'),
code="same_alias")
# Save note
super().save(*args, **kwargs)
@ -81,11 +92,13 @@ 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:
raise ValidationError(_('This alias is already taken.'))
raise ValidationError(_('This alias is already taken.'),
code="same_alias",)
else:
# Alias does not exist yet, so check if it can exist
a = Alias(name=str(self))
@ -94,7 +107,7 @@ class Note(PolymorphicModel):
class NoteUser(Note):
"""
A Note associated to an User
A :model:`note.Note` associated to an unique :model:`auth.User`.
"""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
@ -116,7 +129,7 @@ class NoteUser(Note):
class NoteClub(Note):
"""
A Note associated to a Club
A :model:`note.Note` associated to an unique :model:`member.Club`
"""
club = models.OneToOneField(
'member.Club',
@ -133,17 +146,18 @@ class NoteClub(Note):
return str(self.club)
def pretty(self):
return _("Note for %(club)s club") % {'club': str(self.club)}
return _("Note of %(club)s club") % {'club': str(self.club)}
class NoteSpecial(Note):
"""
A Note for special account, where real money enter or leave the system
A :model:`note.Note` for special accounts, where real money enter or leave the system
- bank check
- credit card
- bank transfer
- cash
- refund
This Type of Note is not associated to a :model:`auth.User` or :model:`member.Club` .
"""
special_type = models.CharField(
verbose_name=_('type'),
@ -161,7 +175,13 @@ class NoteSpecial(Note):
class Alias(models.Model):
"""
An alias labels a Note instance, only for user and clubs
points toward a :model:`note.NoteUser` or :model;`note.NoteClub` instance.
Alias are unique, but a :model:`note.NoteUser` or :model:`note.NoteClub` can
have multiples aliases.
Aliases name are also normalized, two differents :model:`note.Note` can not
have the same normalized alias, to avoid confusion when referring orally to
it.
"""
name = models.CharField(
verbose_name=_('name'),
@ -170,15 +190,15 @@ class Alias(models.Model):
validators=[
RegexValidator(
regex=settings.ALIAS_VALIDATOR_REGEX,
message=_('Invalid alias')
message=_('Invalid alias'),
)
] if settings.ALIAS_VALIDATOR_REGEX else []
] if settings.ALIAS_VALIDATOR_REGEX else [],
)
normalized_name = models.CharField(
max_length=255,
unique=True,
default='',
editable=False
editable=False,
)
note = models.ForeignKey(
Note,
@ -198,27 +218,27 @@ class Alias(models.Model):
Normalizes a string: removes most diacritics and does casefolding
"""
return ''.join(
char
for char in unicodedata.normalize('NFKD', string.casefold())
char for char in unicodedata.normalize('NFKD', string.casefold())
if all(not unicodedata.category(char).startswith(cat)
for cat in {'M', 'P', 'Z', 'C'})
).casefold()
def save(self, *args, **kwargs):
"""
Handle normalized_name
"""
self.normalized_name = Alias.normalize(self.name)
if len(self.normalized_name) < 256:
super().save(*args, **kwargs)
for cat in {'M', 'P', 'Z', 'C'})).casefold()
def clean(self):
normalized_name = Alias.normalize(self.name)
if len(normalized_name) >= 255:
raise ValidationError(_('Alias too long.'))
raise ValidationError(_('Alias is too long.'),
code='alias_too_long')
try:
if self != Alias.objects.get(normalized_name=normalized_name):
raise ValidationError(_('An alias with a similar name '
'already exists.'))
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"
)
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."),
code="cant_delete_main_alias")
return super().delete(using, keep_parents)

View File

@ -1,24 +1,50 @@
# -*- mode: python; coding: utf-8 -*-
# Copyright (C) 2018-2019 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.db import models
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
"""
Defines transactions
"""
class TemplateCategory(models.Model):
"""
Defined a recurrent transaction category
Example: food, softs, ...
"""
name = models.CharField(
verbose_name=_("name"),
max_length=31,
unique=True,
)
class Meta:
verbose_name = _("transaction category")
verbose_name_plural = _("transaction categories")
def __str__(self):
return str(self.name)
class TransactionTemplate(models.Model):
"""
Defined a recurrent transaction
associated to selling something (a burger, a beer, ...)
"""
name = models.CharField(
verbose_name=_('name'),
max_length=255,
unique=True,
error_messages={'unique':_("A template with this name already exist")},
)
destination = models.ForeignKey(
NoteClub,
@ -30,9 +56,18 @@ class TransactionTemplate(models.Model):
verbose_name=_('amount'),
help_text=_('in centimes'),
)
template_type = models.CharField(
category = models.ForeignKey(
TemplateCategory,
on_delete=models.PROTECT,
verbose_name=_('type'),
max_length=31
max_length=31,
)
display = models.BooleanField(
default = True,
)
description = models.CharField(
verbose_name=_('description'),
max_length=255,
)
class Meta:
@ -40,10 +75,17 @@ 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(models.Model):
class Transaction(PolymorphicModel):
"""
General transaction between two :model:`note.Note`
amount is store in centimes of currency, making it a positive integer
value. (from someone to someone else)
"""
source = models.ForeignKey(
Note,
on_delete=models.PROTECT,
@ -64,13 +106,7 @@ class Transaction(models.Model):
verbose_name=_('quantity'),
default=1,
)
amount = models.PositiveIntegerField(
verbose_name=_('amount'),
)
transaction_type = models.CharField(
verbose_name=_('type'),
max_length=31,
)
amount = models.PositiveIntegerField(verbose_name=_('amount'), )
reason = models.CharField(
verbose_name=_('reason'),
max_length=255,
@ -88,6 +124,11 @@ class Transaction(models.Model):
"""
When saving, also transfer money between two notes
"""
if self.source.pk == self.destination.pk:
# When source == destination, no money is transfered
return
created = self.pk is None
to_transfer = self.amount * self.quantity
if not created:
@ -108,10 +149,31 @@ class Transaction(models.Model):
@property
def total(self):
return self.amount*self.quantity
return self.amount * self.quantity
class TemplateTransaction(Transaction):
"""
Special type of :model:`note.Transaction` associated to a :model:`note.TransactionTemplate`.
"""
template = models.ForeignKey(
TransactionTemplate,
null=True,
on_delete=models.SET_NULL,
)
category = models.ForeignKey(
TemplateCategory,
on_delete=models.PROTECT,
)
class MembershipTransaction(Transaction):
"""
Special type of :model:`note.Transaction` associated to a :model:`member.Membership`.
"""
membership = models.OneToOneField(
'member.Membership',
on_delete=models.PROTECT,

View File

@ -1,22 +1,29 @@
# -*- mode: python; coding: utf-8 -*-
# Copyright (C) 2018-2019 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
def save_user_note(instance, created, **_kwargs):
def save_user_note(instance, created, raw, **_kwargs):
"""
Hook to create and save a note when an user is updated
"""
if raw:
# When provisionning data, do not try to autocreate
return
if created:
from .models import NoteUser
NoteUser.objects.create(user=instance)
instance.note.save()
def save_club_note(instance, created, **_kwargs):
def save_club_note(instance, created, raw, **_kwargs):
"""
Hook to create and save a note when a club is updated
"""
if raw:
# When provisionning data, do not try to autocreate
return
if created:
from .models import NoteClub
NoteClub.objects.create(club=instance)

View File

@ -1,20 +1,45 @@
#!/usr/bin/env python
import django_tables2 as tables
from .models.transactions import Transaction
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import django_tables2 as tables
from django.db.models import F
from django_tables2.utils import A
from .models.transactions import Transaction
from .models.notes import Alias
class HistoryTable(tables.Table):
class Meta:
attrs = {'class':'table table-bordered table-condensed table-striped table-hover'}
attrs = {
'class':
'table table-condensed table-striped table-hover'
}
model = Transaction
template_name = 'django_tables2/bootstrap.html'
sequence = ('...','total','valid')
template_name = 'django_tables2/bootstrap4.html'
sequence = ('...', 'total', 'valid')
total = tables.Column() #will use Transaction.total() !!
total = tables.Column() # will use Transaction.total() !!
def order_total(self, QuerySet, is_descending):
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)
queryset = queryset.annotate(total=F('amount') * F('quantity')) \
.order_by(('-' if is_descending else '') + 'total')
return (queryset, True)
class AliasTable(tables.Table):
class Meta:
attrs = {
'class':
'table table condensed table-striped table-hover'
}
model = Alias
fields =('name',)
template_name = 'django_tables2/bootstrap4.html'
show_header = False
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')

View File

@ -1,11 +1,21 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django import template
def pretty_money(value):
if value%100 == 0:
return str(value//100) + ''
if value % 100 == 0:
return "{:s}{:d}".format(
"- " if value < 0 else "",
abs(value) // 100,
)
else:
return str(value//100) + '' + str(value%100)
return "{:s}{:d}{:02d}".format(
"- " if value < 0 else "",
abs(value) // 100,
abs(value) % 100,
)
register = template.Library()

View File

@ -1,15 +1,19 @@
# -*- mode: python; coding: utf-8 -*-
# Copyright (C) 2018-2019 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path
from . import views
from .models import Note
app_name = 'note'
urlpatterns = [
path('transfer/', views.TransactionCreate.as_view(), name='transfer'),
path('buttons/create/',views.TransactionTemplateCreateView.as_view(),name='template_create'),
path('buttons/update/<int:pk>/',views.TransactionTemplateUpdateView.as_view(),name='template_update'),
path('buttons/',views.TransactionTemplateListView.as_view(),name='template_list')
path('buttons/create/', views.TransactionTemplateCreateView.as_view(), name='template_create'),
path('buttons/update/<int:pk>/', views.TransactionTemplateUpdateView.as_view(), name='template_update'),
path('buttons/', views.TransactionTemplateListView.as_view(), name='template_list'),
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'),
]

View File

@ -1,13 +1,16 @@
# -*- mode: python; coding: utf-8 -*-
# Copyright (C) 2018-2019 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2020 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
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, ListView, DetailView, UpdateView
from django.views.generic import CreateView, ListView, UpdateView
from .models import Transaction, TransactionTemplate, Alias, TemplateTransaction
from .forms import TransactionForm, TransactionTemplateForm, ConsoForm
from .models import Transaction,TransactionTemplate
from .forms import TransactionTemplateForm
class TransactionCreate(LoginRequiredMixin, CreateView):
"""
@ -16,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):
"""
@ -25,24 +28,123 @@ class TransactionCreate(LoginRequiredMixin, CreateView):
context = super().get_context_data(**kwargs)
context['title'] = _('Transfer money from your account '
'to one or others')
context['no_cache'] = True
return context
class TransactionTemplateCreateView(LoginRequiredMixin,CreateView):
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.
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:
types = str(note_type).lower()
if "user" in types:
qs = qs.filter(note__polymorphic_ctype__model="noteuser")
elif "club" in types:
qs = qs.filter(note__polymorphic_ctype__model="noteclub")
elif "special" in types:
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
"""
model = TransactionTemplate
form_class = TransactionTemplateForm
class TransactionTemplateListView(LoginRequiredMixin,ListView):
class TransactionTemplateListView(LoginRequiredMixin, ListView):
"""
List TransactionsTemplates
"""
model = TransactionTemplate
form_class = TransactionTemplateForm
class TransactionTemplateUpdateView(LoginRequiredMixin,UpdateView):
class TransactionTemplateUpdateView(LoginRequiredMixin, UpdateView):
"""
"""
model = TransactionTemplate
form_class=TransactionTemplateForm
form_class = TransactionTemplateForm
class ConsoView(LoginRequiredMixin, CreateView):
"""
Consume
"""
model = TemplateTransaction
template_name = "note/conso_form.html"
form_class = ConsoForm
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")
# 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')