Compare commits

..

9 Commits

Author SHA1 Message Date
Yohann D'ANELLO 24ac3ce45f Display users that have surnormal roles 2020-08-05 21:07:31 +02:00
Yohann D'ANELLO 018ca84e2d Prevent superusers when they make a transaction with a non-member user 2020-08-05 20:40:30 +02:00
Yohann D'ANELLO 2851d7764c Profile pictures are clickable 2020-08-05 19:52:36 +02:00
Yohann D'ANELLO c205219d47 🐛 Fix transaction update concurency 2020-08-05 19:42:44 +02:00
Yohann D'ANELLO b0398e59b8 🐛 Fix treasury 2020-08-05 18:04:01 +02:00
Yohann D'ANELLO 9c3e978a41 🐛 Fix signup 2020-08-05 16:27:44 +02:00
Yohann D'ANELLO 21f1347a60 🐛 Fix signup 2020-08-05 16:26:44 +02:00
Yohann D'ANELLO af857d6fae 🐛 Prevent transactions where note balances go out integer bounds 2020-08-05 16:23:32 +02:00
Yohann D'ANELLO acf7ecc4ae Use phone number validator 2020-08-05 14:14:51 +02:00
27 changed files with 870 additions and 635 deletions

View File

@ -4,7 +4,7 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from rest_framework import viewsets from rest_framework import viewsets
from note_kfet.middlewares import get_current_authenticated_user from note_kfet.middlewares import get_current_session
class ReadProtectedModelViewSet(viewsets.ModelViewSet): class ReadProtectedModelViewSet(viewsets.ModelViewSet):
@ -17,7 +17,8 @@ class ReadProtectedModelViewSet(viewsets.ModelViewSet):
self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class() self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
def get_queryset(self): def get_queryset(self):
user = get_current_authenticated_user() user = self.request.user
get_current_session().setdefault("permission_mask", 42)
return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct() return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct()
@ -31,5 +32,6 @@ class ReadOnlyProtectedModelViewSet(viewsets.ReadOnlyModelViewSet):
self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class() self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
def get_queryset(self): def get_queryset(self):
user = get_current_authenticated_user() user = self.request.user
get_current_session().setdefault("permission_mask", 42)
return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct() return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct()

View File

@ -11,10 +11,10 @@ from django.db import models
from django.db.models import Q from django.db.models import Q
from django.template import loader from django.template import loader
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.encoding import force_bytes from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode from django.utils.http import urlsafe_base64_encode
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
from permission.models import Role from permission.models import Role
from registration.tokens import email_validation_token from registration.tokens import email_validation_token
@ -34,7 +34,7 @@ class Profile(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
phone_number = models.CharField( phone_number = PhoneNumberField(
verbose_name=_('phone number'), verbose_name=_('phone number'),
max_length=50, max_length=50,
blank=True, blank=True,

View File

@ -69,7 +69,9 @@ class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
form.fields['email'].required = True form.fields['email'].required = True
form.fields['email'].help_text = _("This address must be valid.") form.fields['email'].help_text = _("This address must be valid.")
context['profile_form'] = self.profile_form(instance=context['user_object'].profile) context['profile_form'] = self.profile_form(instance=context['user_object'].profile,
data=self.request.POST if self.request.POST else None)
return context return context
def form_valid(self, form): def form_valid(self, form):
@ -86,30 +88,33 @@ class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
data=self.request.POST, data=self.request.POST,
instance=self.object.profile, instance=self.object.profile,
) )
if form.is_valid() and profile_form.is_valid(): profile_form.full_clean()
new_username = form.data['username'] if not profile_form.is_valid():
alias = Alias.objects.filter(name=new_username) return super().form_invalid(form)
# 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()
olduser = User.objects.get(pk=form.instance.pk) 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) olduser = User.objects.get(pk=form.instance.pk)
profile = profile_form.save(commit=False)
profile.user = user
profile.save()
user.save()
if olduser.email != user.email: user = form.save(commit=False)
# If the user changed her/his email, then it is unvalidated and a confirmation link is sent. profile = profile_form.save(commit=False)
user.profile.email_confirmed = False profile.user = user
user.profile.save() profile.save()
user.profile.send_email_validation_link() user.save()
if olduser.email != user.email:
# If the user changed her/his email, then it is unvalidated and a confirmation link is sent.
user.profile.email_confirmed = False
user.profile.save()
user.profile.send_email_validation_link()
return super().form_valid(form) return super().form_valid(form)

View File

@ -1,10 +1,15 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.utils import timezone
from rest_framework import serializers from rest_framework import serializers
from rest_framework.serializers import ListSerializer
from rest_polymorphic.serializers import PolymorphicSerializer from rest_polymorphic.serializers import PolymorphicSerializer
from member.api.serializers import MembershipSerializer
from member.models import Membership
from note_kfet.middlewares import get_current_authenticated_user from note_kfet.middlewares import get_current_authenticated_user
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from rest_framework.utils import model_meta
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias
from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory, \ from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory, \
@ -108,6 +113,8 @@ class ConsumerSerializer(serializers.ModelSerializer):
email_confirmed = serializers.SerializerMethodField() email_confirmed = serializers.SerializerMethodField()
membership = serializers.SerializerMethodField()
class Meta: class Meta:
model = Alias model = Alias
fields = '__all__' fields = '__all__'
@ -126,6 +133,17 @@ class ConsumerSerializer(serializers.ModelSerializer):
return obj.note.user.profile.email_confirmed return obj.note.user.profile.email_confirmed
return True return True
def get_membership(self, obj):
if isinstance(obj.note, NoteUser):
memberships = Membership.objects.filter(
PermissionBackend.filter_queryset(get_current_authenticated_user(), Membership, "view")).filter(
user=obj.note.user,
club=2, # Kfet
).order_by("-date_start")
if memberships.exists():
return MembershipSerializer().to_representation(memberships.first())
return None
class TemplateCategorySerializer(serializers.ModelSerializer): class TemplateCategorySerializer(serializers.ModelSerializer):
""" """
@ -209,5 +227,23 @@ class TransactionPolymorphicSerializer(PolymorphicSerializer):
except ImportError: # Activity app is not loaded except ImportError: # Activity app is not loaded
pass pass
def validate(self, attrs):
resource_type = attrs.pop(self.resource_type_field_name)
serializer = self._get_serializer_from_resource_type(resource_type)
if self.instance:
instance = self.instance
info = model_meta.get_field_info(instance)
for attr, value in attrs.items():
if attr in info.relations and info.relations[attr].to_many:
field = getattr(instance, attr)
field.set(value)
else:
setattr(instance, attr, value)
instance.validate(True)
else:
serializer.Meta.model(**attrs).validate(True)
attrs[self.resource_type_field_name] = resource_type
return super().validate(attrs)
class Meta: class Meta:
model = Transaction model = Transaction

View File

@ -9,7 +9,7 @@ from rest_framework import viewsets
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet
from note_kfet.middlewares import get_current_authenticated_user from note_kfet.middlewares import get_current_session
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer,\ from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer,\
@ -154,5 +154,7 @@ class TransactionViewSet(ReadProtectedModelViewSet):
search_fields = ['$reason', ] search_fields = ['$reason', ]
def get_queryset(self): def get_queryset(self):
user = get_current_authenticated_user() user = self.request.user
return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view")) get_current_session().setdefault("permission_mask", 42)
return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view"))\
.order_by("created_at", "id")

View File

@ -1,7 +1,7 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models, transaction
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -164,28 +164,9 @@ class Transaction(PolymorphicModel):
models.Index(fields=['destination']), models.Index(fields=['destination']),
] ]
def save(self, *args, **kwargs): def validate(self, reset=False):
""" previous_source_balance = self.source.balance
When saving, also transfer money between two notes previous_dest_balance = self.destination.balance
"""
if not self.source.is_active or not self.destination.is_active:
if 'force_insert' not in kwargs or not kwargs['force_insert']:
if 'force_update' not in kwargs or not kwargs['force_update']:
raise ValidationError(_("The transaction can't be saved since the source note "
"or the destination note is not active."))
# If the aliases are not entered, we assume that the used alias is the name of the note
if not self.source_alias:
self.source_alias = str(self.source)
if not self.destination_alias:
self.destination_alias = str(self.destination)
if self.source.pk == self.destination.pk:
# When source == destination, no money is transferred
super().save(*args, **kwargs)
return
created = self.pk is None created = self.pk is None
to_transfer = self.amount * self.quantity to_transfer = self.amount * self.quantity
@ -204,14 +185,63 @@ class Transaction(PolymorphicModel):
# previously invalid # previously invalid
self.invalidity_reason = None self.invalidity_reason = None
# We save first the transaction, in case of the user has no right to transfer money source_balance = self.source.balance
super().save(*args, **kwargs) dest_balance = self.destination.balance
# Save notes if reset:
self.source._force_save = True self.source.balance = previous_source_balance
self.source.save() self.destination.balance = previous_dest_balance
self.destination._force_save = True
self.destination.save() if source_balance > 2147483647 or source_balance < -2147483648\
or dest_balance > 2147483647 or dest_balance < -2147483648:
raise ValidationError(_("The note balances must be between - 21 474 836.47 € and 21 474 836.47 €."))
@transaction.atomic
def save(self, *args, **kwargs):
"""
When saving, also transfer money between two notes
"""
with transaction.atomic():
if self.pk:
self.refresh_from_db()
self.source.refresh_from_db()
self.destination.refresh_from_db()
self.validate(False)
if not self.source.is_active or not self.destination.is_active:
if 'force_insert' not in kwargs or not kwargs['force_insert']:
if 'force_update' not in kwargs or not kwargs['force_update']:
raise ValidationError(_("The transaction can't be saved since the source note "
"or the destination note is not active."))
# If the aliases are not entered, we assume that the used alias is the name of the note
if not self.source_alias:
self.source_alias = str(self.source)
if not self.destination_alias:
self.destination_alias = str(self.destination)
if self.source.pk == self.destination.pk:
# When source == destination, no money is transferred
super().save(*args, **kwargs)
return
self.log("Saving")
# We save first the transaction, in case of the user has no right to transfer money
super().save(*args, **kwargs)
self.log("Saved")
# Save notes
self.source._force_save = True
self.source.save()
self.log("Source saved")
self.destination._force_save = True
self.destination.save()
self.log("Destination saved")
def log(self, msg):
with open("/tmp/log", "a") as f:
f.write(msg + "\n")
def delete(self, **kwargs): def delete(self, **kwargs):
""" """

View File

@ -10,7 +10,7 @@ from time import sleep
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.mail import mail_admins from django.core.mail import mail_admins
from django.db import models from django.db import models, transaction
from django.db.models import F, Q, Model from django.db.models import F, Q, Model
from django.forms import model_to_dict from django.forms import model_to_dict
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -43,35 +43,28 @@ class InstancedPermission:
obj = copy(obj) obj = copy(obj)
obj.pk = 0 obj.pk = 0
# Ensure previous models are deleted with transaction.atomic():
for ignored in range(1000): for o in self.model.model_class().objects.filter(pk=0).all():
if self.model.model_class().objects.filter(pk=0).exists(): o._force_delete = True
# If the object exists, that means that one permission is currently checked. Model.delete(o)
# We wait before the other permission, at most 1 second. # An object with pk 0 wouldn't deleted. That's not normal, we alert admins.
sleep(0.001) msg = "Lors de la vérification d'une permission d'ajout, un objet de clé primaire nulle était "\
continue "encore présent.\n"\
break "Type de permission : " + self.type + "\n"\
for o in self.model.model_class().objects.filter(pk=0).all(): "Modèle : " + str(self.model) + "\n"\
o._force_delete = True "Objet trouvé : " + str(model_to_dict(o)) + "\n\n"\
Model.delete(o) "--\nLe BDE"
# An object with pk 0 wouldn't deleted. That's not normal, we alert admins. mail_admins("[Note Kfet] Un objet a été supprimé de force", msg)
msg = "Lors de la vérification d'une permission d'ajout, un objet de clé primaire nulle était "\
"encore présent.\n"\
"Type de permission : " + self.type + "\n"\
"Modèle : " + str(self.model) + "\n"\
"Objet trouvé : " + str(model_to_dict(o)) + "\n\n"\
"--\nLe BDE"
mail_admins("[Note Kfet] Un objet a été supprimé de force", msg)
# Force insertion, no data verification, no trigger # Force insertion, no data verification, no trigger
obj._force_save = True obj._force_save = True
Model.save(obj, force_insert=True) Model.save(obj, force_insert=True)
# We don't want log anything # We don't want log anything
obj._no_log = True obj._no_log = True
ret = self.model.model_class().objects.filter(self.query & Q(pk=0)).exists() ret = self.model.model_class().objects.filter(self.query & Q(pk=0)).exists()
# Delete testing object # Delete testing object
obj._force_delete = True obj._force_delete = True
Model.delete(obj) Model.delete(obj)
with open("/tmp/log", "w") as f: with open("/tmp/log", "w") as f:
f.write(str(obj) + ", " + str(obj.pk) + ", " + str(self.model.model_class().objects.filter(pk=0).exists())) f.write(str(obj) + ", " + str(obj.pk) + ", " + str(self.model.model_class().objects.filter(pk=0).exists()))

51
apps/permission/tables.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
import django_tables2 as tables
from django.urls import reverse_lazy
from django.utils.html import format_html
from member.models import Membership
from note_kfet.middlewares import get_current_authenticated_user
from permission.backends import PermissionBackend
class RightsTable(tables.Table):
"""
List managers of a club.
"""
def render_user(self, value):
# If the user has the right, link the displayed user with the page of its detail.
s = value.username
if PermissionBackend.check_perm(get_current_authenticated_user(), "auth.view_user", value):
s = format_html("<a href={url}>{name}</a>",
url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s)
return s
def render_club(self, value):
# If the user has the right, link the displayed user with the page of its detail.
s = value.name
if PermissionBackend.check_perm(get_current_authenticated_user(), "member.view_club", value):
s = format_html("<a href={url}>{name}</a>",
url=reverse_lazy('member:club_detail', kwargs={"pk": value.pk}), name=s)
return s
def render_roles(self, record):
# If the user has the right to manage the roles, display the link to manage them
roles = record.roles.all()
s = ", ".join(str(role) for role in roles)
if PermissionBackend.check_perm(get_current_authenticated_user(), "member.change_membership_roles", record):
s = format_html("<a href='" + str(reverse_lazy("member:club_manage_roles", kwargs={"pk": record.pk}))
+ "'>" + s + "</a>")
return s
class Meta:
attrs = {
'class': 'table table-condensed table-striped table-hover',
'style': 'table-layout: fixed;'
}
template_name = 'django_tables2/bootstrap4.html'
fields = ('user.last_name', 'user.first_name', 'user', 'club', 'roles', )
model = Membership

View File

@ -2,13 +2,16 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from datetime import date from datetime import date
from django.db.models import Q
from django.forms import HiddenInput from django.forms import HiddenInput
from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import UpdateView, TemplateView from django.views.generic import UpdateView, TemplateView
from member.models import Membership from member.models import Membership
from .backends import PermissionBackend from .backends import PermissionBackend
from .models import Role from .models import Role
from .tables import RightsTable
class ProtectQuerysetMixin: class ProtectQuerysetMixin:
@ -59,4 +62,16 @@ class RightsView(TemplateView):
for role in roles: for role in roles:
role.clubs = [membership.club for membership in active_memberships if role in membership.roles.all()] role.clubs = [membership.club for membership in active_memberships if role in membership.roles.all()]
if self.request.user.is_authenticated:
special_memberships = Membership.objects.filter(
date_start__lte=timezone.now().date(),
date_end__gte=timezone.now().date(),
).filter(roles__in=Role.objects.filter(~(Q(name="Adhérent BDE")
| Q(name="Adhérent Kfet")
| Q(name="Membre de club")
| Q(name="Adhérent WEI")
| Q(name="1A")))).order_by("club", "user__last_name")\
.distinct().all()
context["special_memberships_table"] = RightsTable(special_memberships)
return context return context

View File

@ -39,7 +39,7 @@ class UserCreateView(CreateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["profile_form"] = self.second_form() context["profile_form"] = self.second_form(self.request.POST if self.request.POST else None)
del context["profile_form"].fields["section"] del context["profile_form"].fields["section"]
return context return context

View File

@ -1,11 +1,10 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import datetime
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit from crispy_forms.layout import Submit
from django import forms from django import forms
from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from note_kfet.inputs import DatePickerInput, AmountInput from note_kfet.inputs import DatePickerInput, AmountInput
@ -19,12 +18,13 @@ class InvoiceForm(forms.ModelForm):
# Django forms don't support date fields. We have to add it manually # Django forms don't support date fields. We have to add it manually
date = forms.DateField( date = forms.DateField(
initial=datetime.date.today, initial=timezone.now,
widget=DatePickerInput() widget=DatePickerInput(),
) )
def clean_date(self): def clean_date(self):
self.instance.date = self.data.get("date") self.instance.date = self.data.get("date")
return self.instance.date
class Meta: class Meta:
model = Invoice model = Invoice
@ -36,7 +36,11 @@ class ProductForm(forms.ModelForm):
model = Product model = Product
fields = '__all__' fields = '__all__'
widgets = { widgets = {
"amount": AmountInput() "amount": AmountInput(
attrs={
"negative": True,
}
)
} }
@ -115,6 +119,12 @@ class LinkTransactionToRemittanceForm(forms.ModelForm):
""" """
Attach a special transaction to a remittance. Attach a special transaction to a remittance.
""" """
remittance = forms.ModelChoiceField(
queryset=Remittance.objects.none(),
label=_("Remittance"),
empty_label=_("No attached remittance"),
required=False,
)
# Since we use a proxy model for special transactions, we add manually the fields related to the transaction # Since we use a proxy model for special transactions, we add manually the fields related to the transaction
last_name = forms.CharField(label=_("Last name")) last_name = forms.CharField(label=_("Last name"))
@ -123,7 +133,7 @@ class LinkTransactionToRemittanceForm(forms.ModelForm):
bank = forms.Field(label=_("Bank")) bank = forms.Field(label=_("Bank"))
amount = forms.IntegerField(label=_("Amount"), min_value=0) amount = forms.IntegerField(label=_("Amount"), min_value=0, widget=AmountInput(), disabled=True, required=False)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -133,33 +143,19 @@ class LinkTransactionToRemittanceForm(forms.ModelForm):
self.fields["remittance"].queryset = Remittance.objects.filter(closed=False) self.fields["remittance"].queryset = Remittance.objects.filter(closed=False)
def clean_last_name(self): def clean(self):
""" cleaned_data = super().clean()
Replace the first name in the information of the transaction. self.instance.transaction.last_name = cleaned_data["last_name"]
""" self.instance.transaction.first_name = cleaned_data["first_name"]
self.instance.transaction.last_name = self.data.get("last_name") self.instance.transaction.bank = cleaned_data["bank"]
self.instance.transaction.clean() return cleaned_data
def clean_first_name(self): def save(self, commit=True):
""" """
Replace the last name in the information of the transaction. Save the transaction and the remittance.
""" """
self.instance.transaction.first_name = self.data.get("first_name") self.instance.transaction.save()
self.instance.transaction.clean() return super().save(commit)
def clean_bank(self):
"""
Replace the bank in the information of the transaction.
"""
self.instance.transaction.bank = self.data.get("bank")
self.instance.transaction.clean()
def clean_amount(self):
"""
Replace the amount of the transaction.
"""
self.instance.transaction.amount = self.data.get("amount")
self.instance.transaction.clean()
class Meta: class Meta:
model = SpecialTransactionProxy model = SpecialTransactionProxy

View File

@ -82,7 +82,7 @@ class Product(models.Model):
verbose_name=_("Designation"), verbose_name=_("Designation"),
) )
quantity = models.PositiveIntegerField( quantity = models.IntegerField(
verbose_name=_("Quantity") verbose_name=_("Quantity")
) )

View File

@ -64,6 +64,7 @@ class RemittanceTable(tables.Table):
model = Remittance model = Remittance
template_name = 'django_tables2/bootstrap4.html' template_name = 'django_tables2/bootstrap4.html'
fields = ('id', 'date', 'remittance_type', 'comment', 'count', 'amount', 'view',) fields = ('id', 'date', 'remittance_type', 'comment', 'count', 'amount', 'view',)
order_by = ('-date',)
class SpecialTransactionTable(tables.Table): class SpecialTransactionTable(tables.Table):
@ -100,7 +101,8 @@ class SpecialTransactionTable(tables.Table):
} }
model = SpecialTransaction model = SpecialTransaction
template_name = 'django_tables2/bootstrap4.html' template_name = 'django_tables2/bootstrap4.html'
fields = ('id', 'source', 'destination', 'last_name', 'first_name', 'bank', 'amount', 'reason',) fields = ('created_at', 'source', 'destination', 'last_name', 'first_name', 'bank', 'amount', 'reason',)
order_by = ('-created_at',)
class SogeCreditTable(tables.Table): class SogeCreditTable(tables.Table):

View File

@ -238,7 +238,7 @@ class RemittanceListView(LoginRequiredMixin, TemplateView):
closed_remittances = RemittanceTable( closed_remittances = RemittanceTable(
data=Remittance.objects.filter(closed=True).filter( data=Remittance.objects.filter(closed=True).filter(
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).reverse().all(), PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all(),
prefix="closed-remittances-", prefix="closed-remittances-",
) )
closed_remittances.paginate(page=self.request.GET.get("closed-remittances-page", 1), per_page=10) closed_remittances.paginate(page=self.request.GET.get("closed-remittances-page", 1), per_page=10)
@ -281,8 +281,6 @@ class RemittanceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["table"] = RemittanceTable(data=Remittance.objects.filter(
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all())
data = SpecialTransaction.objects.filter(specialtransactionproxy__remittance=self.object).filter( data = SpecialTransaction.objects.filter(specialtransactionproxy__remittance=self.object).filter(
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all() PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all()
context["special_transactions"] = SpecialTransactionTable( context["special_transactions"] = SpecialTransactionTable(

View File

@ -8,6 +8,8 @@ from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
from member.models import Club, Membership from member.models import Club, Membership
from note.models import MembershipTransaction from note.models import MembershipTransaction
from permission.models import Role from permission.models import Role
@ -223,7 +225,7 @@ class WEIRegistration(models.Model):
verbose_name=_("emergency contact name"), verbose_name=_("emergency contact name"),
) )
emergency_contact_phone = models.CharField( emergency_contact_phone = PhoneNumberField(
max_length=32, max_length=32,
verbose_name=_("emergency contact phone"), verbose_name=_("emergency contact phone"),
) )

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -37,6 +37,7 @@ INSTALLED_APPS = [
# External apps # External apps
'mailer', 'mailer',
'phonenumber_field',
'polymorphic', 'polymorphic',
'crispy_forms', 'crispy_forms',
'django_tables2', 'django_tables2',
@ -195,3 +196,7 @@ MEDIA_URL = '/media/'
# Profile Picture Settings # Profile Picture Settings
PIC_WIDTH = 200 PIC_WIDTH = 200
PIC_RATIO = 1 PIC_RATIO = 1
PHONENUMBER_DB_FORMAT = 'NATIONAL'
PHONENUMBER_DEFAULT_REGION = 'FR'

View File

@ -7,11 +7,13 @@ django-crispy-forms==1.7.2
django-extensions==2.1.9 django-extensions==2.1.9
django-filter==2.2.0 django-filter==2.2.0
django-mailer==2.0.1 django-mailer==2.0.1
django-phonenumber-field==4.0.0
django-polymorphic==2.0.3 django-polymorphic==2.0.3
django-tables2==2.1.0 django-tables2==2.1.0
docutils==0.14 docutils==0.14
idna==2.8 idna==2.8
oauthlib==3.1.0 oauthlib==3.1.0
phonenumbers==8.12.7
Pillow==7.1.2 Pillow==7.1.2
python3-openid==3.1.0 python3-openid==3.1.0
pytz==2019.1 pytz==2019.1

View File

@ -105,8 +105,10 @@ function displayStyle(note) {
css += " text-danger"; css += " text-danger";
else if (balance < 0) else if (balance < 0)
css += " text-warning"; css += " text-warning";
if (!note.email_confirmed) else if (!note.email_confirmed)
css += " text-white bg-primary"; css += " text-white bg-primary";
else if (note.membership && note.membership.date_end < new Date().toISOString())
css += "text-white bg-info";
return css; return css;
} }
@ -131,13 +133,9 @@ function displayNote(note, alias, user_note_field = null, profile_pic_field = nu
$("#" + user_note_field).text(alias + (note.balance == null ? "" : (" :\n" + pretty_money(note.balance)))); $("#" + user_note_field).text(alias + (note.balance == null ? "" : (" :\n" + pretty_money(note.balance))));
if (profile_pic_field != null) { if (profile_pic_field != null) {
$("#" + profile_pic_field).attr('src', img); $("#" + profile_pic_field).attr('src', img);
$("#" + profile_pic_field).click(function () { $("#" + profile_pic_field + "_link").attr('href', note.resourcetype === "NoteUser" ?
if (note.resourcetype === "NoteUser") { "/accounts/user/" + note.user : note.resourcetype === "NoteClub" ?
document.location.href = "/accounts/user/" + note.user; "/accounts/club/" + note.club : "#");
} else if (note.resourcetype === "NoteClub") {
document.location.href = "/accounts/club/" + note.club;
}
});
} }
} }
} }
@ -267,6 +265,7 @@ function autoCompleteNote(field_id, note_list_id, notes, notes_display, alias_pr
consumers.results.forEach(function (consumer) { consumers.results.forEach(function (consumer) {
let note = consumer.note; let note = consumer.note;
note.email_confirmed = consumer.email_confirmed; note.email_confirmed = consumer.email_confirmed;
note.membership = consumer.membership;
let extra_css = displayStyle(note); let extra_css = displayStyle(note);
aliases_matched_html += li(alias_prefix + '_' + consumer.id, aliases_matched_html += li(alias_prefix + '_' + consumer.id,
consumer.name, consumer.name,
@ -371,8 +370,12 @@ function de_validate(id, validated) {
refreshHistory(); refreshHistory();
}, },
error: function (err) { error: function (err) {
let errObj = JSON.parse(err.responseText);
let error = errObj["detail"] ? errObj["detail"] : errObj["non_field_errors"];
if (!error)
error = err.responseText;
addMsg("Une erreur est survenue lors de la validation/dévalidation " + addMsg("Une erreur est survenue lors de la validation/dévalidation " +
"de cette transaction : " + JSON.parse(err.responseText)["detail"], "danger", 10000); "de cette transaction : " + error, "danger");
refreshBalance(); refreshBalance();
// error if this method doesn't exist. Please define it. // error if this method doesn't exist. Please define it.

View File

@ -145,6 +145,7 @@ function reset() {
$("#consos_list").html(""); $("#consos_list").html("");
$("#user_note").text(""); $("#user_note").text("");
$("#profile_pic").attr("src", "/media/pic/default.png"); $("#profile_pic").attr("src", "/media/pic/default.png");
$("#profile_pic_link").attr("href", "#");
refreshHistory(); refreshHistory();
refreshBalance(); refreshBalance();
} }
@ -212,11 +213,14 @@ function consume(source, source_alias, dest, quantity, amount, reason, type, cat
if (newBalance <= -5000) if (newBalance <= -5000)
addMsg("Attention, La transaction depuis la note " + source_alias + " a été réalisée avec " + addMsg("Attention, La transaction depuis la note " + source_alias + " a été réalisée avec " +
"succès, mais la note émettrice " + source_alias + " est en négatif sévère.", "succès, mais la note émettrice " + source_alias + " est en négatif sévère.",
"danger", 10000); "danger", 30000);
else if (newBalance < 0) else if (newBalance < 0)
addMsg("Attention, La transaction depuis la note " + source_alias + " a été réalisée avec " + addMsg("Attention, La transaction depuis la note " + source_alias + " a été réalisée avec " +
"succès, mais la note émettrice " + source_alias + " est en négatif.", "succès, mais la note émettrice " + source_alias + " est en négatif.",
"warning", 10000); "warning", 30000);
if (source.note.membership && source.note.membership.date_end > new Date().toISOString())
addMsg("Attention : la note émettrice " + source.name + " n'est plus adhérente.",
"danger", 30000);
} }
reset(); reset();
}).fail(function (e) { }).fail(function (e) {
@ -240,7 +244,7 @@ function consume(source, source_alias, dest, quantity, amount, reason, type, cat
addMsg("La transaction n'a pas pu être validée pour cause de solde insuffisant.", "danger", 10000); addMsg("La transaction n'a pas pu être validée pour cause de solde insuffisant.", "danger", 10000);
}).fail(function () { }).fail(function () {
reset(); reset();
errMsg(e.responseJSON, 10000); errMsg(e.responseJSON);
}); });
}); });
} }

View File

@ -37,6 +37,7 @@ function reset(refresh=true) {
$("#bank").val(""); $("#bank").val("");
$("#user_note").val(""); $("#user_note").val("");
$("#profile_pic").attr("src", "/media/pic/default.png"); $("#profile_pic").attr("src", "/media/pic/default.png");
$("#profile_pic_link").attr("href", "#");
if (refresh) { if (refresh) {
refreshBalance(); refreshBalance();
refreshHistory(); refreshHistory();
@ -213,6 +214,13 @@ $("#btn_transfer").click(function() {
error = true; error = true;
} }
let amount = Math.floor(100 * amount_field.val());
if (amount > 2147483647) {
amount_field.addClass('is-invalid');
$("#amount-required").html("<strong>Le montant ne doit pas excéder 21474836.47 €.</strong>");
error = true;
}
if (!reason_field.val()) { if (!reason_field.val()) {
reason_field.addClass('is-invalid'); reason_field.addClass('is-invalid');
$("#reason-required").html("<strong>Ce champ est requis.</strong>"); $("#reason-required").html("<strong>Ce champ est requis.</strong>");
@ -232,7 +240,6 @@ $("#btn_transfer").click(function() {
if (error) if (error)
return; return;
let amount = 100 * amount_field.val();
let reason = reason_field.val(); let reason = reason_field.val();
if ($("#type_transfer").is(':checked')) { if ($("#type_transfer").is(':checked')) {
@ -253,6 +260,13 @@ $("#btn_transfer").click(function() {
"destination": dest.note.id, "destination": dest.note.id,
"destination_alias": dest.name "destination_alias": dest.name
}).done(function () { }).done(function () {
if (source.note.membership && source.note.membership.date_end > new Date().toISOString())
addMsg("Attention : la note émettrice " + source.name + " n'est plus adhérente.",
"danger", 30000);
if (dest.note.membership && dest.note.membership.date_end > new Date().toISOString())
addMsg("Attention : la note destination " + dest.name + " n'est plus adhérente.",
"danger", 30000);
if (!isNaN(source.note.balance)) { if (!isNaN(source.note.balance)) {
let newBalance = source.note.balance - source.quantity * dest.quantity * amount; let newBalance = source.note.balance - source.quantity * dest.quantity * amount;
if (newBalance <= -5000) { if (newBalance <= -5000) {
@ -277,7 +291,15 @@ $("#btn_transfer").click(function() {
+ " vers la note " + dest.name + " a été fait avec succès !", "success", 10000); + " vers la note " + dest.name + " a été fait avec succès !", "success", 10000);
reset(); reset();
}).fail(function () { // do it again but valid = false }).fail(function (err) { // do it again but valid = false
let errObj = JSON.parse(err.responseText);
if (errObj["non_field_errors"]) {
addMsg("Le transfert de "
+ pretty_money(source.quantity * dest.quantity * amount) + " de la note " + source.name
+ " vers la note " + dest.name + " a échoué : " + errObj["non_field_errors"], "danger");
return;
}
$.post("/api/note/transaction/transaction/", $.post("/api/note/transaction/transaction/",
{ {
"csrfmiddlewaretoken": CSRF_TOKEN, "csrfmiddlewaretoken": CSRF_TOKEN,
@ -298,9 +320,13 @@ $("#btn_transfer").click(function() {
+ " vers la note " + dest.name + " a échoué : Solde insuffisant", "danger", 10000); + " vers la note " + dest.name + " a échoué : Solde insuffisant", "danger", 10000);
reset(); reset();
}).fail(function (err) { }).fail(function (err) {
let errObj = JSON.parse(err.responseText);
let error = errObj["detail"] ? errObj["detail"] : errObj["non_field_errors"]
if (!error)
error = err.responseText;
addMsg("Le transfert de " addMsg("Le transfert de "
+ pretty_money(source.quantity * dest.quantity * amount) + " de la note " + source.name + pretty_money(source.quantity * dest.quantity * amount) + " de la note " + source.name
+ " vers la note " + dest.name + " a échoué : " + err.responseText, "danger"); + " vers la note " + dest.name + " a échoué : " + error, "danger");
}); });
}); });
}); });
@ -308,19 +334,22 @@ $("#btn_transfer").click(function() {
} else if ($("#type_credit").is(':checked') || $("#type_debit").is(':checked')) { } else if ($("#type_credit").is(':checked') || $("#type_debit").is(':checked')) {
let special_note = $("#credit_type").val(); let special_note = $("#credit_type").val();
let user_note; let user_note;
let alias;
let given_reason = reason; let given_reason = reason;
let source_id, dest_id; let source_id, dest_id;
if ($("#type_credit").is(':checked')) { if ($("#type_credit").is(':checked')) {
user_note = dests_notes_display[0].note.id; user_note = dests_notes_display[0].note;
alias = dests_notes_display[0].name;
source_id = special_note; source_id = special_note;
dest_id = user_note; dest_id = user_note.id;
reason = "Crédit " + $("#credit_type option:selected").text().toLowerCase(); reason = "Crédit " + $("#credit_type option:selected").text().toLowerCase();
if (given_reason.length > 0) if (given_reason.length > 0)
reason += " (" + given_reason + ")"; reason += " (" + given_reason + ")";
} }
else { else {
user_note = sources_notes_display[0].note.id; user_note = sources_notes_display[0].note;
source_id = user_note; alias = sources_notes_display[0].name;
source_id = user_note.id;
dest_id = special_note; dest_id = special_note;
reason = "Retrait " + $("#credit_type option:selected").text().toLowerCase(); reason = "Retrait " + $("#credit_type option:selected").text().toLowerCase();
if (given_reason.length > 0) if (given_reason.length > 0)
@ -336,18 +365,23 @@ $("#btn_transfer").click(function() {
"polymorphic_ctype": SPECIAL_TRANSFER_POLYMORPHIC_CTYPE, "polymorphic_ctype": SPECIAL_TRANSFER_POLYMORPHIC_CTYPE,
"resourcetype": "SpecialTransaction", "resourcetype": "SpecialTransaction",
"source": source_id, "source": source_id,
"source_alias": sources_notes_display.length ? sources_notes_display[0].name : null, "source_alias": sources_notes_display.length ? alias : null,
"destination": dest_id, "destination": dest_id,
"destination_alias": dests_notes_display.length ? dests_notes_display[0].name : null, "destination_alias": dests_notes_display.length ? alias : null,
"last_name": $("#last_name").val(), "last_name": $("#last_name").val(),
"first_name": $("#first_name").val(), "first_name": $("#first_name").val(),
"bank": $("#bank").val() "bank": $("#bank").val()
}).done(function () { }).done(function () {
addMsg("Le crédit/retrait a bien été effectué !", "success", 10000); addMsg("Le crédit/retrait a bien été effectué !", "success", 10000);
if (user_note.membership && user_note.membership.date_end > new Date().toISOString())
addMsg("Attention : la note " + alias + " n'est plus adhérente.", "danger", 10000);
reset(); reset();
}).fail(function (err) { }).fail(function (err) {
addMsg("Le crédit/retrait a échoué : " + JSON.parse(err.responseText)["detail"], let errObj = JSON.parse(err.responseText);
"danger", 10000); let error = errObj["detail"] ? errObj["detail"] : errObj["non_field_errors"]
if (!error)
error = err.responseText;
addMsg("Le crédit/retrait a échoué : " + error, "danger", 10000);
}); });
} }
}); });

View File

@ -1,5 +1,5 @@
<div class="input-group"> <div class="input-group">
<input class="form-control mx-auto d-block" type="number" min="0" step="0.01" <input class="form-control mx-auto d-block" type="number" {% if not widget.attrs.negative %}min="0"{% endif %} step="0.01"
{% if widget.value != None and widget.value != "" %}value="{{ widget.value }}"{% endif %} {% if widget.value != None and widget.value != "" %}value="{{ widget.value }}"{% endif %}
name="{{ widget.name }}" name="{{ widget.name }}"
{% for name, value in widget.attrs.items %} {% for name, value in widget.attrs.items %}

View File

@ -12,8 +12,10 @@
{# User details column #} {# User details column #}
<div class="col"> <div class="col">
<div class="card border-success shadow mb-4 text-center"> <div class="card border-success shadow mb-4 text-center">
<img src="/media/pic/default.png" <a id="profile_pic_link" href="#">
id="profile_pic" alt="" class="card-img-top"> <img src="/media/pic/default.png"
id="profile_pic" alt="" class="card-img-top">
</a>
<div class="card-body text-center"> <div class="card-body text-center">
<span id="user_note"></span> <span id="user_note"></span>
</div> </div>

View File

@ -36,8 +36,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="row"> <div class="row">
<div class="col-md-3" id="note_infos_div"> <div class="col-md-3" id="note_infos_div">
<div class="card border-success shadow mb-4"> <div class="card border-success shadow mb-4">
<img src="/media/pic/default.png" <a id="profile_pic_link" href="#"><img src="/media/pic/default.png"
id="profile_pic" alt="" class="img-fluid rounded mx-auto d-block"> id="profile_pic" alt="" class="img-fluid rounded mx-auto d-block"></a>
<div class="card-body text-center"> <div class="card-body text-center">
<span id="user_note"></span> <span id="user_note"></span>
</div> </div>

View File

@ -1,8 +1,17 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load i18n %} {% load i18n %}
{% load render_table from django_tables2 %}
{% block content %} {% block content %}
{% if user.is_authenticated %}
<h2>{% trans "Users that have surnormal rights" %}</h2>
{% render_table special_memberships_table %}
<hr>
{% endif %}
<h2>{% trans "Roles description" %}</h2>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<div class="form-check"> <div class="form-check">
<label for="owned_only" class="form-check-label"> <label for="owned_only" class="form-check-label">
@ -11,6 +20,7 @@
</label> </label>
</div> </div>
{% endif %} {% endif %}
<ul> <ul>
{% regroup active_memberships by roles as memberships_per_role %} {% regroup active_memberships by roles as memberships_per_role %}
{% for role in roles %} {% for role in roles %}

View File

@ -1,4 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load i18n %}
{% block content %} {% block content %}
<h2>{% trans "Account activation" %}</h2> <h2>{% trans "Account activation" %}</h2>