Merge branch 'master' into tranfer_front

# Conflicts:
#	static/js/base.js
This commit is contained in:
Yohann D'ANELLO 2020-04-09 22:49:52 +02:00
commit bac81cd13e
264 changed files with 5777 additions and 50313 deletions

View File

@ -1,12 +0,0 @@
[run]
source =
activity
member
note
omit =
activity/tests/*.py
activity/migrations/*.py
member/tests/*.py
member/migrations/*.py
note/tests/*.py
note/migrations/*.py

View File

@ -1,13 +1,13 @@
DJANGO_APP_STAGE="dev" DJANGO_APP_STAGE=dev
# Only used in dev mode, change to "postgresql" if you want to use PostgreSQL in dev # Only used in dev mode, change to "postgresql" if you want to use PostgreSQL in dev
DJANGO_DEV_STORE_METHOD="sqllite" DJANGO_DEV_STORE_METHOD=sqllite
DJANGO_DB_HOST="localhost" DJANGO_DB_HOST=localhost
DJANGO_DB_NAME="note_db" DJANGO_DB_NAME=note_db
DJANGO_DB_USER="note" DJANGO_DB_USER=note
DJANGO_DB_PASSWORD="CHANGE_ME" DJANGO_DB_PASSWORD=CHANGE_ME
DJANGO_DB_PORT="" DJANGO_DB_PORT=
DJANGO_SECRET_KEY="CHANGE_ME" DJANGO_SECRET_KEY=CHANGE_ME
DJANGO_SETTINGS_MODULE="note_kfet.settings" DJANGO_SETTINGS_MODULE=note_kfet.settings
DOMAIN="localhost" DOMAIN=localhost
CONTACT_EMAIL="tresorerie.bde@localhost" CONTACT_EMAIL=tresorerie.bde@localhost
NOTE_URL="localhost" NOTE_URL=localhost

View File

@ -18,7 +18,6 @@ COPY . /code/
# Comment what is not needed # Comment what is not needed
RUN pip install -r requirements/base.txt RUN pip install -r requirements/base.txt
RUN pip install -r requirements/api.txt
RUN pip install -r requirements/cas.txt RUN pip install -r requirements/cas.txt
RUN pip install -r requirements/production.txt RUN pip install -r requirements/production.txt

View File

@ -106,18 +106,18 @@ On supposera pour la suite que vous utilisez Debian/Ubuntu sur un serveur tout n
On copie le fichier `.env_example` vers le fichier `.env` à la racine du projet On copie le fichier `.env_example` vers le fichier `.env` à la racine du projet
et on renseigne des secrets et des paramètres : et on renseigne des secrets et des paramètres :
DJANGO_APP_STAGE="dev" # ou "prod" DJANGO_APP_STAGE=dev # ou "prod"
DJANGO_DEV_STORE_METHOD="sqllite" # ou "postgres" DJANGO_DEV_STORE_METHOD=sqllite # ou "postgres"
DJANGO_DB_HOST="localhost" DJANGO_DB_HOST=localhost
DJANGO_DB_NAME="note_db" DJANGO_DB_NAME=note_db
DJANGO_DB_USER="note" DJANGO_DB_USER=note
DJANGO_DB_PASSWORD="CHANGE_ME" DJANGO_DB_PASSWORD=CHANGE_ME
DJANGO_DB_PORT="" DJANGO_DB_PORT=
DJANGO_SECRET_KEY="CHANGE_ME" DJANGO_SECRET_KEY=CHANGE_ME
DJANGO_SETTINGS_MODULE="note_kfet.settings" DJANGO_SETTINGS_MODULE="note_kfet.settings
DOMAIN="localhost" # note.example.com DOMAIN=localhost # note.example.com
CONTACT_EMAIL="tresorerie.bde@localhost" CONTACT_EMAIL=tresorerie.bde@localhost
NOTE_URL="localhost" # serveur cas note.example.com si auto-hébergé. NOTE_URL=localhost # serveur cas note.example.com si auto-hébergé.
Ensuite on (re)bascule dans l'environement virtuel et on lance les migrations Ensuite on (re)bascule dans l'environement virtuel et on lance les migrations

View File

@ -3,7 +3,7 @@
from rest_framework import serializers from rest_framework import serializers
from ..models import ActivityType, Activity, Guest from ..models import ActivityType, Activity, Guest, Entry, GuestTransaction
class ActivityTypeSerializer(serializers.ModelSerializer): class ActivityTypeSerializer(serializers.ModelSerializer):
@ -37,3 +37,25 @@ class GuestSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Guest model = Guest
fields = '__all__' fields = '__all__'
class EntrySerializer(serializers.ModelSerializer):
"""
REST API Serializer for Entries.
The djangorestframework plugin will analyse the model `Entry` and parse all fields in the API.
"""
class Meta:
model = Entry
fields = '__all__'
class GuestTransactionSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Special transactions.
The djangorestframework plugin will analyse the model `GuestTransaction` and parse all fields in the API.
"""
class Meta:
model = GuestTransaction
fields = '__all__'

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 .views import ActivityTypeViewSet, ActivityViewSet, GuestViewSet from .views import ActivityTypeViewSet, ActivityViewSet, GuestViewSet, EntryViewSet
def register_activity_urls(router, path): def register_activity_urls(router, path):
@ -11,3 +11,4 @@ def register_activity_urls(router, path):
router.register(path + '/activity', ActivityViewSet) router.register(path + '/activity', ActivityViewSet)
router.register(path + '/type', ActivityTypeViewSet) router.register(path + '/type', ActivityTypeViewSet)
router.register(path + '/guest', GuestViewSet) router.register(path + '/guest', GuestViewSet)
router.register(path + '/entry', EntryViewSet)

View File

@ -5,8 +5,8 @@ from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter from rest_framework.filters import SearchFilter
from api.viewsets import ReadProtectedModelViewSet from api.viewsets import ReadProtectedModelViewSet
from .serializers import ActivityTypeSerializer, ActivitySerializer, GuestSerializer from .serializers import ActivityTypeSerializer, ActivitySerializer, GuestSerializer, EntrySerializer
from ..models import ActivityType, Activity, Guest from ..models import ActivityType, Activity, Guest, Entry
class ActivityTypeViewSet(ReadProtectedModelViewSet): class ActivityTypeViewSet(ReadProtectedModelViewSet):
@ -42,4 +42,16 @@ class GuestViewSet(ReadProtectedModelViewSet):
queryset = Guest.objects.all() queryset = Guest.objects.all()
serializer_class = GuestSerializer serializer_class = GuestSerializer
filter_backends = [SearchFilter] filter_backends = [SearchFilter]
search_fields = ['$name', ] search_fields = ['$last_name', '$first_name', '$inviter__alias__name', '$inviter__alias__normalized_name', ]
class EntryViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Entry` objects, serialize it to JSON with the given serializer,
then render it on /api/activity/entry/
"""
queryset = Entry.objects.all()
serializer_class = EntrySerializer
filter_backends = [SearchFilter]
search_fields = ['$last_name', '$first_name', '$inviter__alias__name', '$inviter__alias__normalized_name', ]

View File

@ -0,0 +1,20 @@
[
{
"model": "activity.activitytype",
"pk": 1,
"fields": {
"name": "Pot",
"can_invite": true,
"guest_entry_fee": 500
}
},
{
"model": "activity.activitytype",
"pk": 2,
"fields": {
"name": "Soir\u00e9e de club",
"can_invite": false,
"guest_entry_fee": 0
}
}
]

84
apps/activity/forms.py Normal file
View File

@ -0,0 +1,84 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import timedelta, datetime
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _
from member.models import Club
from note.models import NoteUser, Note
from note_kfet.inputs import DateTimePickerInput, Autocomplete
from .models import Activity, Guest
class ActivityForm(forms.ModelForm):
class Meta:
model = Activity
exclude = ('creater', 'valid', 'open', )
widgets = {
"organizer": Autocomplete(
model=Club,
attrs={"api_url": "/api/members/club/"},
),
"note": Autocomplete(
model=Note,
attrs={
"api_url": "/api/note/note/",
'placeholder': 'Note de l\'événement sur laquelle envoyer les crédits d\'invitation ...'
},
),
"attendees_club": Autocomplete(
model=Club,
attrs={"api_url": "/api/members/club/"},
),
"date_start": DateTimePickerInput(),
"date_end": DateTimePickerInput(),
}
class GuestForm(forms.ModelForm):
def clean(self):
cleaned_data = super().clean()
if self.activity.date_start > datetime.now():
self.add_error("inviter", _("You can't invite someone once the activity is started."))
if not self.activity.valid:
self.add_error("inviter", _("This activity is not validated yet."))
one_year = timedelta(days=365)
qs = Guest.objects.filter(
first_name=cleaned_data["first_name"],
last_name=cleaned_data["last_name"],
activity__date_start__gte=self.activity.date_start - one_year,
)
if len(qs) >= 5:
self.add_error("last_name", _("This person has been already invited 5 times this year."))
qs = qs.filter(activity=self.activity)
if qs.exists():
self.add_error("last_name", _("This person is already invited."))
qs = Guest.objects.filter(inviter=cleaned_data["inviter"], activity=self.activity)
if len(qs) >= 3:
self.add_error("inviter", _("You can't invite more than 3 people to this activity."))
return cleaned_data
class Meta:
model = Guest
fields = ('last_name', 'first_name', 'inviter', )
widgets = {
"inviter": Autocomplete(
NoteUser,
attrs={
'api_url': '/api/note/note/',
# We don't evaluate the content type at launch because the DB might be not initialized
'api_url_suffix':
lambda: '&polymorphic_ctype=' + str(ContentType.objects.get_for_model(NoteUser).pk),
'placeholder': 'Note ...',
},
),
}

View File

@ -1,9 +1,13 @@
# 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 datetime import timedelta, datetime
from django.conf import settings from django.contrib.auth.models import User
from django.db import models from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import ValidationError
from note.models import NoteUser, Transaction
class ActivityType(models.Model): class ActivityType(models.Model):
@ -44,39 +48,127 @@ class Activity(models.Model):
verbose_name=_('name'), verbose_name=_('name'),
max_length=255, max_length=255,
) )
description = models.TextField( description = models.TextField(
verbose_name=_('description'), verbose_name=_('description'),
) )
activity_type = models.ForeignKey( activity_type = models.ForeignKey(
ActivityType, ActivityType,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+', related_name='+',
verbose_name=_('type'), verbose_name=_('type'),
) )
creater = models.ForeignKey(
User,
on_delete=models.PROTECT,
verbose_name=_("user"),
)
organizer = models.ForeignKey( organizer = models.ForeignKey(
'member.Club', 'member.Club',
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+', related_name='+',
verbose_name=_('organizer'), verbose_name=_('organizer'),
) )
attendees_club = models.ForeignKey( attendees_club = models.ForeignKey(
'member.Club', 'member.Club',
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+', related_name='+',
verbose_name=_('attendees club'), verbose_name=_('attendees club'),
) )
date_start = models.DateTimeField( date_start = models.DateTimeField(
verbose_name=_('start date'), verbose_name=_('start date'),
) )
date_end = models.DateTimeField( date_end = models.DateTimeField(
verbose_name=_('end date'), verbose_name=_('end date'),
) )
valid = models.BooleanField(
default=False,
verbose_name=_('valid'),
)
open = models.BooleanField(
default=False,
verbose_name=_('open'),
)
class Meta: class Meta:
verbose_name = _("activity") verbose_name = _("activity")
verbose_name_plural = _("activities") verbose_name_plural = _("activities")
class Entry(models.Model):
"""
Register the entry of someone:
- a member with a :model:`note.NoteUser`
- or a :model:`activity.Guest`
In the case of a Guest Entry, the inviter note is also save.
"""
activity = models.ForeignKey(
Activity,
on_delete=models.PROTECT,
related_name="entries",
verbose_name=_("activity"),
)
time = models.DateTimeField(
auto_now_add=True,
verbose_name=_("entry time"),
)
note = models.ForeignKey(
NoteUser,
on_delete=models.PROTECT,
verbose_name=_("note"),
)
guest = models.OneToOneField(
'activity.Guest',
on_delete=models.PROTECT,
null=True,
)
class Meta:
unique_together = (('activity', 'note', 'guest', ), )
verbose_name = _("entry")
verbose_name_plural = _("entries")
def save(self, *args,**kwargs):
qs = Entry.objects.filter(~Q(pk=self.pk), activity=self.activity, note=self.note, guest=self.guest)
if qs.exists():
raise ValidationError(_("Already entered on ") + _("{:%Y-%m-%d %H:%M:%S}").format(qs.get().time, ))
if self.guest:
self.note = self.guest.inviter
insert = not self.pk
if insert:
if self.note.balance < 0:
raise ValidationError(_("The balance is negative."))
ret = super().save(*args,**kwargs)
if insert and self.guest:
GuestTransaction.objects.create(
source=self.note,
destination=self.activity.organizer.note,
quantity=1,
amount=self.activity.activity_type.guest_entry_fee,
reason="Invitation " + self.activity.name + " " + self.guest.first_name + " " + self.guest.last_name,
valid=True,
guest=self.guest,
).save()
return ret
class Guest(models.Model): class Guest(models.Model):
""" """
People who are not current members of any clubs, and are 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.
@ -86,24 +178,73 @@ class Guest(models.Model):
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+', related_name='+',
) )
name = models.CharField(
last_name = models.CharField(
max_length=255, max_length=255,
verbose_name=_("last name"),
) )
first_name = models.CharField(
max_length=255,
verbose_name=_("first name"),
)
inviter = models.ForeignKey( inviter = models.ForeignKey(
settings.AUTH_USER_MODEL, NoteUser,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+', related_name='guests',
) verbose_name=_("inviter"),
entry = models.DateTimeField(
null=True,
)
entry_transaction = models.ForeignKey(
'note.Transaction',
on_delete=models.PROTECT,
blank=True,
null=True,
) )
@property
def has_entry(self):
try:
if self.entry:
return True
return False
except AttributeError:
return False
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
one_year = timedelta(days=365)
if not force_insert:
if self.activity.date_start > datetime.now():
raise ValidationError(_("You can't invite someone once the activity is started."))
if not self.activity.valid:
raise ValidationError(_("This activity is not validated yet."))
qs = Guest.objects.filter(
first_name=self.first_name,
last_name=self.last_name,
activity__date_start__gte=self.activity.date_start - one_year,
)
if len(qs) >= 5:
raise ValidationError(_("This person has been already invited 5 times this year."))
qs = qs.filter(activity=self.activity)
if qs.exists():
raise ValidationError(_("This person is already invited."))
qs = Guest.objects.filter(inviter=self.inviter, activity=self.activity)
if len(qs) >= 3:
raise ValidationError(_("You can't invite more than 3 people to this activity."))
return super().save(force_insert, force_update, using, update_fields)
class Meta: class Meta:
verbose_name = _("guest") verbose_name = _("guest")
verbose_name_plural = _("guests") verbose_name_plural = _("guests")
unique_together = ("activity", "last_name", "first_name", )
class GuestTransaction(Transaction):
guest = models.OneToOneField(
Guest,
on_delete=models.PROTECT,
)
@property
def type(self):
return _('Invitation')

108
apps/activity/tables.py Normal file
View File

@ -0,0 +1,108 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
import django_tables2 as tables
from django_tables2 import A
from note.templatetags.pretty_money import pretty_money
from .models import Activity, Guest, Entry
class ActivityTable(tables.Table):
name = tables.LinkColumn(
'activity:activity_detail',
args=[A('pk'), ],
)
class Meta:
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
model = Activity
template_name = 'django_tables2/bootstrap4.html'
fields = ('name', 'activity_type', 'organizer', 'attendees_club', 'date_start', 'date_end', )
class GuestTable(tables.Table):
inviter = tables.LinkColumn(
'member:user_detail',
args=[A('inviter.user.pk'), ],
)
entry = tables.Column(
empty_values=(),
attrs={
"td": {
"class": lambda record: "" if record.has_entry else "validate btn btn-danger",
"onclick": lambda record: "" if record.has_entry else "remove_guest(" + str(record.pk) + ")"
}
}
)
class Meta:
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
model = Guest
template_name = 'django_tables2/bootstrap4.html'
fields = ("last_name", "first_name", "inviter", )
def render_entry(self, record):
if record.has_entry:
return str(_("Entered on ") + str(_("{:%Y-%m-%d %H:%M:%S}").format(record.entry.time, )))
return _("remove").capitalize()
def get_row_class(record):
c = "table-row"
if isinstance(record, Guest):
if record.has_entry:
c += " table-success"
else:
c += " table-warning"
else:
qs = Entry.objects.filter(note=record.note, activity=record.activity, guest=None)
if qs.exists():
c += " table-success"
elif record.note.balance < 0:
c += " table-danger"
return c
class EntryTable(tables.Table):
type = tables.Column(verbose_name=_("Type"))
last_name = tables.Column(verbose_name=_("Last name"))
first_name = tables.Column(verbose_name=_("First name"))
note_name = tables.Column(verbose_name=_("Note"))
balance = tables.Column(verbose_name=_("Balance"))
def render_note_name(self, value, record):
if hasattr(record, 'username'):
username = record.username
if username != value:
return format_html(value + " <em>aka.</em> " + username)
return value
def render_balance(self, value):
return pretty_money(value)
class Meta:
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
template_name = 'django_tables2/bootstrap4.html'
row_attrs = {
'class': lambda record: get_row_class(record),
'id': lambda record: "row-" + ("guest-" if isinstance(record, Guest) else "membership-") + str(record.pk),
'data-type': lambda record: "guest" if isinstance(record, Guest) else "membership",
'data-id': lambda record: record.pk if isinstance(record, Guest) else record.note.pk,
'data-inviter': lambda record: record.inviter.pk if isinstance(record, Guest) else "",
'data-last-name': lambda record: record.last_name,
'data-first-name': lambda record: record.first_name,
}

17
apps/activity/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 django.urls import path
from . import views
app_name = 'activity'
urlpatterns = [
path('', views.ActivityListView.as_view(), name='activity_list'),
path('<int:pk>/', views.ActivityDetailView.as_view(), name='activity_detail'),
path('<int:pk>/invite/', views.ActivityInviteView.as_view(), name='activity_invite'),
path('<int:pk>/entry/', views.ActivityEntryView.as_view(), name='activity_entry'),
path('<int:pk>/update/', views.ActivityUpdateView.as_view(), name='activity_update'),
path('new/', views.ActivityCreateView.as_view(), name='activity_create'),
]

161
apps/activity/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 datetime import datetime, timezone
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.db.models import F, Q
from django.urls import reverse_lazy
from django.views.generic import CreateView, DetailView, UpdateView, TemplateView
from django.utils.translation import gettext_lazy as _
from django_tables2.views import SingleTableView
from note.models import NoteUser, Alias, NoteSpecial
from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin
from .forms import ActivityForm, GuestForm
from .models import Activity, Guest, Entry
from .tables import ActivityTable, GuestTable, EntryTable
class ActivityCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
model = Activity
form_class = ActivityForm
def form_valid(self, form):
form.instance.creater = self.request.user
return super().form_valid(form)
def get_success_url(self, **kwargs):
self.object.refresh_from_db()
return reverse_lazy('activity:activity_detail', kwargs={"pk": self.object.pk})
class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
model = Activity
table_class = ActivityTable
def get_queryset(self):
return super().get_queryset().reverse()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = _("Activities")
upcoming_activities = Activity.objects.filter(date_end__gt=datetime.now())
context['upcoming'] = ActivityTable(data=upcoming_activities
.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")))
return context
class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
model = Activity
context_object_name = "activity"
def get_context_data(self, **kwargs):
context = super().get_context_data()
table = GuestTable(data=Guest.objects.filter(activity=self.object)
.filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view")))
context["guests"] = table
context["activity_started"] = datetime.now(timezone.utc) > self.object.date_start
return context
class ActivityUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
model = Activity
form_class = ActivityForm
def get_success_url(self, **kwargs):
return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]})
class ActivityInviteView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
model = Guest
form_class = GuestForm
template_name = "activity/activity_invite.html"
def get_form(self, form_class=None):
form = super().get_form(form_class)
form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\
.get(pk=self.kwargs["pk"])
return form
def form_valid(self, form):
form.instance.activity = Activity.objects\
.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")).get(pk=self.kwargs["pk"])
return super().form_valid(form)
def get_success_url(self, **kwargs):
return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]})
class ActivityEntryView(LoginRequiredMixin, TemplateView):
template_name = "activity/activity_entry.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\
.get(pk=self.kwargs["pk"])
context["activity"] = activity
matched = []
pattern = "^$"
if "search" in self.request.GET:
pattern = self.request.GET["search"]
if not pattern:
pattern = "^$"
if pattern[0] != "^":
pattern = "^" + pattern
guest_qs = Guest.objects\
.annotate(balance=F("inviter__balance"), note_name=F("inviter__user__username"))\
.filter(Q(first_name__regex=pattern) | Q(last_name__regex=pattern)
| Q(inviter__alias__name__regex=pattern)
| Q(inviter__alias__normalized_name__regex=Alias.normalize(pattern))) \
.filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))\
.distinct()[:20]
for guest in guest_qs:
guest.type = "Invité"
matched.append(guest)
note_qs = Alias.objects.annotate(last_name=F("note__noteuser__user__last_name"),
first_name=F("note__noteuser__user__first_name"),
username=F("note__noteuser__user__username"),
note_name=F("name"),
balance=F("note__balance"))\
.filter(Q(note__polymorphic_ctype__model="noteuser")
& (Q(note__noteuser__user__first_name__regex=pattern)
| Q(note__noteuser__user__last_name__regex=pattern)
| Q(name__regex=pattern)
| Q(normalized_name__regex=Alias.normalize(pattern)))) \
.filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view"))\
.distinct()[:20]
for note in note_qs:
note.type = "Adhérent"
note.activity = activity
matched.append(note)
table = EntryTable(data=matched)
context["table"] = table
context["entries"] = Entry.objects.filter(activity=activity)
context["title"] = _('Entry for activity "{}"').format(activity.name)
context["noteuser_ctype"] = ContentType.objects.get_for_model(NoteUser).pk
context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk
context["activities_open"] = Activity.objects.filter(open=True).filter(
PermissionBackend.filter_queryset(self.request.user, Activity, "view")).filter(
PermissionBackend.filter_queryset(self.request.user, Activity, "change")).all()
return context

View File

@ -75,3 +75,7 @@ class Changelog(models.Model):
def delete(self, using=None, keep_parents=False): def delete(self, using=None, keep_parents=False):
raise ValidationError(_("Logs cannot be destroyed.")) raise ValidationError(_("Logs cannot be destroyed."))
class Meta:
verbose_name = _("changelog")
verbose_name_plural = _("changelogs")

View File

@ -50,6 +50,9 @@ def save_object(sender, instance, **kwargs):
if instance._meta.label_lower in EXCLUDED: if instance._meta.label_lower in EXCLUDED:
return return
if hasattr(instance, "_no_log"):
return
# noinspection PyProtectedMember # noinspection PyProtectedMember
previous = instance._previous previous = instance._previous
@ -106,6 +109,9 @@ def delete_object(sender, instance, **kwargs):
if instance._meta.label_lower in EXCLUDED: if instance._meta.label_lower in EXCLUDED:
return return
if hasattr(instance, "_no_log"):
return
# Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP # Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP
user, ip = get_current_authenticated_user(), get_current_ip() user, ip = get_current_authenticated_user(), get_current_ip()

View File

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

@ -5,10 +5,12 @@
"fields": { "fields": {
"name": "BDE", "name": "BDE",
"email": "tresorerie.bde@example.com", "email": "tresorerie.bde@example.com",
"membership_fee": 500, "require_memberships": true,
"membership_duration": "396 00:00:00", "membership_fee_paid": 500,
"membership_start": "213 00:00:00", "membership_fee_unpaid": 500,
"membership_end": "273 00:00:00" "membership_duration": 396,
"membership_start": "2019-08-31",
"membership_end": "2020-09-30"
} }
}, },
{ {
@ -17,10 +19,13 @@
"fields": { "fields": {
"name": "Kfet", "name": "Kfet",
"email": "tresorerie.bde@example.com", "email": "tresorerie.bde@example.com",
"membership_fee": 3500, "parent_club": 1,
"membership_duration": "396 00:00:00", "require_memberships": true,
"membership_start": "213 00:00:00", "membership_fee_paid": 3500,
"membership_end": "273 00:00:00" "membership_fee_unpaid": 3500,
"membership_duration": 396,
"membership_start": "2019-08-31",
"membership_end": "2020-09-30"
} }
} }
] ]

View File

@ -1,13 +1,12 @@
# 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 crispy_forms.bootstrap import Div
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout
from dal import autocomplete
from django import forms from django import forms
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils.translation import gettext_lazy as _
from note.models import NoteSpecial
from note_kfet.inputs import Autocomplete, AmountInput, DatePickerInput
from permission.models import PermissionMask from permission.models import PermissionMask
from .models import Profile, Club, Membership from .models import Profile, Club, Membership
@ -21,17 +20,6 @@ class CustomAuthenticationForm(AuthenticationForm):
) )
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): class ProfileForm(forms.ModelForm):
""" """
A form for the extras field provided by the :model:`member.Profile` model. A form for the extras field provided by the :model:`member.Profile` model.
@ -40,21 +28,64 @@ class ProfileForm(forms.ModelForm):
class Meta: class Meta:
model = Profile model = Profile
fields = '__all__' fields = '__all__'
exclude = ['user'] exclude = ('user', 'email_confirmed', 'registration_valid', 'soge', )
class ClubForm(forms.ModelForm): class ClubForm(forms.ModelForm):
class Meta: class Meta:
model = Club model = Club
fields = '__all__' fields = '__all__'
widgets = {
"membership_fee_paid": AmountInput(),
class AddMembersForm(forms.Form): "membership_fee_unpaid": AmountInput(),
class Meta: "parent_club": Autocomplete(
fields = ('',) Club,
attrs={
'api_url': '/api/members/club/',
}
),
"membership_start": DatePickerInput(),
"membership_end": DatePickerInput(),
}
class MembershipForm(forms.ModelForm): class MembershipForm(forms.ModelForm):
soge = forms.BooleanField(
label=_("Inscription paid by Société Générale"),
required=False,
help_text=_("Check this case is the Société Générale paid the inscription."),
)
credit_type = forms.ModelChoiceField(
queryset=NoteSpecial.objects,
label=_("Credit type"),
empty_label=_("No credit"),
required=False,
help_text=_("You can credit the note of the user."),
)
credit_amount = forms.IntegerField(
label=_("Credit amount"),
required=False,
initial=0,
widget=AmountInput(),
)
last_name = forms.CharField(
label=_("Last name"),
required=False,
)
first_name = forms.CharField(
label=_("First name"),
required=False,
)
bank = forms.CharField(
label=_("Bank"),
required=False,
)
class Meta: class Meta:
model = Membership model = Membership
fields = ('user', 'roles', 'date_start') fields = ('user', 'roles', 'date_start')
@ -63,35 +94,13 @@ class MembershipForm(forms.ModelForm):
# et récupère les noms d'utilisateur valides # et récupère les noms d'utilisateur valides
widgets = { widgets = {
'user': 'user':
autocomplete.ModelSelect2( Autocomplete(
url='member:user_autocomplete', User,
attrs={ attrs={
'data-placeholder': 'Nom ...', 'api_url': '/api/user/',
'data-minimum-input-length': 1, 'name_field': 'username',
'placeholder': 'Nom ...',
}, },
), ),
'date_start': DatePickerInput(),
} }
MemberFormSet = forms.modelformset_factory(
Membership,
form=MembershipForm,
extra=2,
can_delete=True,
)
class FormSetHelper(FormHelper):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.form_tag = False
self.form_method = 'POST'
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'),
css_class="row formset-row",
))

View File

@ -2,12 +2,19 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import datetime import datetime
import os
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.template import loader
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.encoding import force_bytes
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 registration.tokens import email_validation_token
from note.models import MembershipTransaction
class Profile(models.Model): class Profile(models.Model):
@ -43,6 +50,23 @@ class Profile(models.Model):
) )
paid = models.BooleanField( paid = models.BooleanField(
verbose_name=_("paid"), verbose_name=_("paid"),
help_text=_("Tells if the user receive a salary."),
default=False,
)
email_confirmed = models.BooleanField(
verbose_name=_("email confirmed"),
default=False,
)
registration_valid = models.BooleanField(
verbose_name=_("registration valid"),
default=False,
)
soge = models.BooleanField(
verbose_name=_("Société générale"),
help_text=_("Has the user ever be paid by the Société générale?"),
default=False, default=False,
) )
@ -54,6 +78,17 @@ class Profile(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('user_detail', args=(self.pk,)) return reverse('user_detail', args=(self.pk,))
def send_email_validation_link(self):
subject = "Activate your Note Kfet account"
message = loader.render_to_string('registration/mails/email_validation_email.html',
{
'user': self.user,
'domain': os.getenv("NOTE_URL", "note.example.com"),
'token': email_validation_token.make_token(self.user),
'uid': urlsafe_base64_encode(force_bytes(self.user.pk)).decode('UTF-8'),
})
self.user.email_user(subject, message)
class Club(models.Model): class Club(models.Model):
""" """
@ -77,22 +112,43 @@ class Club(models.Model):
) )
# Memberships # Memberships
membership_fee = models.PositiveIntegerField(
verbose_name=_('membership fee'), # When set to False, the membership system won't be used.
# Useful to create notes for activities or departments.
require_memberships = models.BooleanField(
default=True,
verbose_name=_("require memberships"),
help_text=_("Uncheck if this club don't require memberships."),
) )
membership_duration = models.DurationField(
membership_fee_paid = models.PositiveIntegerField(
default=0,
verbose_name=_('membership fee (paid students)'),
)
membership_fee_unpaid = models.PositiveIntegerField(
default=0,
verbose_name=_('membership fee (unpaid students)'),
)
membership_duration = models.PositiveIntegerField(
blank=True,
null=True, null=True,
verbose_name=_('membership duration'), verbose_name=_('membership duration'),
help_text=_('The longest time a membership can last ' help_text=_('The longest time (in days) a membership can last '
'(NULL = infinite).'), '(NULL = infinite).'),
) )
membership_start = models.DurationField(
membership_start = models.DateField(
blank=True,
null=True, null=True,
verbose_name=_('membership start'), verbose_name=_('membership start'),
help_text=_('How long after January 1st the members can renew ' help_text=_('How long after January 1st the members can renew '
'their membership.'), 'their membership.'),
) )
membership_end = models.DurationField(
membership_end = models.DateField(
blank=True,
null=True, null=True,
verbose_name=_('membership end'), verbose_name=_('membership end'),
help_text=_('How long the membership can last after January 1st ' help_text=_('How long the membership can last after January 1st '
@ -100,6 +156,33 @@ class Club(models.Model):
'membership.'), 'membership.'),
) )
def update_membership_dates(self):
"""
This function is called each time the club detail view is displayed.
Update the year of the membership dates.
"""
if not self.membership_start:
return
today = datetime.date.today()
if (today - self.membership_start).days >= 365:
self.membership_start = datetime.date(self.membership_start.year + 1,
self.membership_start.month, self.membership_start.day)
self.membership_end = datetime.date(self.membership_end.year + 1,
self.membership_end.month, self.membership_end.day)
self.save(force_update=True)
def save(self, force_insert=False, force_update=False, using=None,
update_fields=None):
if not self.require_memberships:
self.membership_fee_paid = 0
self.membership_fee_unpaid = 0
self.membership_duration = None
self.membership_start = None
self.membership_end = None
super().save(force_insert, force_update, update_fields)
class Meta: class Meta:
verbose_name = _("club") verbose_name = _("club")
verbose_name_plural = _("clubs") verbose_name_plural = _("clubs")
@ -114,9 +197,6 @@ class Club(models.Model):
class Role(models.Model): class Role(models.Model):
""" """
Role that an :model:`auth.User` can have in a :model:`member.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( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
@ -138,40 +218,101 @@ class Membership(models.Model):
""" """
user = models.ForeignKey( user = models.ForeignKey(
settings.AUTH_USER_MODEL, User,
on_delete=models.PROTECT, on_delete=models.PROTECT,
verbose_name=_("user"),
) )
club = models.ForeignKey( club = models.ForeignKey(
Club, Club,
on_delete=models.PROTECT, on_delete=models.PROTECT,
verbose_name=_("club"),
) )
roles = models.ForeignKey(
roles = models.ManyToManyField(
Role, Role,
on_delete=models.PROTECT, verbose_name=_("roles"),
) )
date_start = models.DateField( date_start = models.DateField(
default=datetime.date.today,
verbose_name=_('membership starts on'), verbose_name=_('membership starts on'),
) )
date_end = models.DateField( date_end = models.DateField(
verbose_name=_('membership ends on'), verbose_name=_('membership ends on'),
null=True, null=True,
) )
fee = models.PositiveIntegerField( fee = models.PositiveIntegerField(
verbose_name=_('fee'), verbose_name=_('fee'),
) )
def valid(self): def valid(self):
"""
A membership is valid if today is between the start and the end date.
"""
if self.date_end is not None: if self.date_end is not None:
return self.date_start.toordinal() <= datetime.datetime.now().toordinal() < self.date_end.toordinal() return self.date_start.toordinal() <= datetime.datetime.now().toordinal() < self.date_end.toordinal()
else: else:
return self.date_start.toordinal() <= datetime.datetime.now().toordinal() return self.date_start.toordinal() <= datetime.datetime.now().toordinal()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""
Calculate fee and end date before saving the membership and creating the transaction if needed.
"""
if self.club.parent_club is not None: if self.club.parent_club is not None:
if not Membership.objects.filter(user=self.user, club=self.club.parent_club): if not Membership.objects.filter(user=self.user, club=self.club.parent_club).exists():
raise ValidationError(_('User is not a member of the parent club')) raise ValidationError(_('User is not a member of the parent club') + ' ' + self.club.parent_club.name)
created = not self.pk
if created:
if Membership.objects.filter(
user=self.user,
club=self.club,
date_start__lte=self.date_start,
date_end__gte=self.date_start,
).exists():
raise ValidationError(_('User is already a member of the club'))
if self.user.profile.paid:
self.fee = self.club.membership_fee_paid
else:
self.fee = self.club.membership_fee_unpaid
if self.club.membership_duration is not None:
self.date_end = self.date_start + datetime.timedelta(days=self.club.membership_duration)
else:
self.date_end = self.date_start + datetime.timedelta(days=424242)
if self.club.membership_end is not None and self.date_end > self.club.membership_end:
self.date_end = self.club.membership_end
super().save(*args, **kwargs) super().save(*args, **kwargs)
self.make_transaction()
def make_transaction(self):
"""
Create Membership transaction associated to this membership.
"""
if not self.fee or MembershipTransaction.objects.filter(membership=self).exists():
return
if self.fee:
transaction = MembershipTransaction(
membership=self,
source=self.user.note,
destination=self.club.note,
quantity=1,
amount=self.fee,
reason="Adhésion " + self.club.name,
)
transaction._force_save = True
transaction.save(force_insert=True)
def __str__(self):
return _("Membership of {user} for the club {club}").format(user=self.user.username, club=self.club.name, )
class Meta: class Meta:
verbose_name = _('membership') verbose_name = _('membership')
verbose_name_plural = _('memberships') verbose_name_plural = _('memberships')

View File

@ -10,7 +10,7 @@ def save_user_profile(instance, created, raw, **_kwargs):
# When provisionning data, do not try to autocreate # When provisionning data, do not try to autocreate
return return
if created: if created and instance.is_active:
from .models import Profile from .models import Profile
Profile.objects.get_or_create(user=instance) Profile.objects.get_or_create(user=instance)
instance.profile.save() instance.profile.save()

View File

@ -1,13 +1,23 @@
# 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 datetime import datetime
import django_tables2 as tables import django_tables2 as tables
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils.translation import gettext_lazy as _
from django.urls import reverse_lazy
from django.utils.html import format_html
from note.templatetags.pretty_money import pretty_money
from note_kfet.middlewares import get_current_authenticated_user
from permission.backends import PermissionBackend
from .models import Club from .models import Club, Membership
class ClubTable(tables.Table): class ClubTable(tables.Table):
"""
List all clubs.
"""
class Meta: class Meta:
attrs = { attrs = {
'class': 'table table-condensed table-striped table-hover' 'class': 'table table-condensed table-striped table-hover'
@ -23,8 +33,15 @@ class ClubTable(tables.Table):
class UserTable(tables.Table): class UserTable(tables.Table):
"""
List all users.
"""
section = tables.Column(accessor='profile.section') section = tables.Column(accessor='profile.section')
solde = tables.Column(accessor='note.balance')
balance = tables.Column(accessor='note.balance', verbose_name=_("Balance"))
def render_balance(self, value):
return pretty_money(value)
class Meta: class Meta:
attrs = { attrs = {
@ -33,3 +50,82 @@ class UserTable(tables.Table):
template_name = 'django_tables2/bootstrap4.html' template_name = 'django_tables2/bootstrap4.html'
fields = ('last_name', 'first_name', 'username', 'email') fields = ('last_name', 'first_name', 'username', 'email')
model = User model = User
row_attrs = {
'class': 'table-row',
'data-href': lambda record: record.pk
}
class MembershipTable(tables.Table):
"""
List all memberships.
"""
roles = tables.Column(
attrs={
"td": {
"class": "text-truncate",
}
}
)
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 club 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_fee(self, value, record):
t = pretty_money(value)
# If it is required and if the user has the right, the renew button is displayed.
if record.club.membership_start is not None:
if record.date_start < record.club.membership_start: # If the renew is available
if not Membership.objects.filter(
club=record.club,
user=record.user,
date_start__gte=record.club.membership_start,
date_end__lte=record.club.membership_end,
).exists(): # If the renew is not yet performed
empty_membership = Membership(
club=record.club,
user=record.user,
date_start=datetime.now().date(),
date_end=datetime.now().date(),
fee=0,
)
if PermissionBackend.check_perm(get_current_authenticated_user(),
"member:add_membership", empty_membership): # If the user has right
t = format_html(t + ' <a class="btn btn-warning" href="{url}">{text}</a>',
url=reverse_lazy('member:club_renew_membership',
kwargs={"pk": record.pk}), text=_("Renew"))
return t
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', 'club', 'date_start', 'date_end', 'roles', 'fee', )
model = Membership

View File

@ -7,20 +7,20 @@ from . import views
app_name = 'member' app_name = 'member'
urlpatterns = [ urlpatterns = [
path('signup/', views.UserCreateView.as_view(), name="signup"),
path('club/', views.ClubListView.as_view(), name="club_list"), 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('club/create/', views.ClubCreateView.as_view(), name="club_create"),
path('club/<int:pk>/update', views.ClubUpdateView.as_view(), name="club_update"), path('club/<int:pk>/', views.ClubDetailView.as_view(), name="club_detail"),
path('club/<int:pk>/update_pic', views.ClubPictureUpdateView.as_view(), name="club_update_pic"), path('club/<int:club_pk>/add_member/', views.ClubAddMemberView.as_view(), name="club_add_member"),
path('club/<int:pk>/aliases', views.ClubAliasView.as_view(), name="club_alias"), path('club/manage_roles/<int:pk>/', views.ClubManageRolesView.as_view(), name="club_manage_roles"),
path('club/renew_membership/<int:pk>/', views.ClubAddMemberView.as_view(), name="club_renew_membership"),
path('club/<int:pk>/update/', views.ClubUpdateView.as_view(), name="club_update"),
path('club/<int:pk>/update_pic/', views.ClubPictureUpdateView.as_view(), name="club_update_pic"),
path('club/<int:pk>/aliases/', views.ClubAliasView.as_view(), name="club_alias"),
path('user/', views.UserListView.as_view(), name="user_list"), path('user/', views.UserListView.as_view(), name="user_list"),
path('user/<int:pk>', views.UserDetailView.as_view(), name="user_detail"), 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/', 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>/update_pic/', views.ProfilePictureUpdateView.as_view(), name="user_update_pic"),
path('user/<int:pk>/aliases', views.ProfileAliasView.as_view(), name="user_alias"), path('user/<int:pk>/aliases/', views.ProfileAliasView.as_view(), name="user_alias"),
path('manage-auth-token/', views.ManageAuthTokens.as_view(), name='auth_token'), 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

@ -2,39 +2,37 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import io import io
from datetime import datetime, timedelta
from PIL import Image from PIL import Image
from dal import autocomplete
from django.conf import settings from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth.views import LoginView from django.contrib.auth.views import LoginView
from django.core.exceptions import ValidationError
from django.db.models import Q from django.db.models import Q
from django.http import HttpResponseRedirect
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, UpdateView, TemplateView, DeleteView from django.views.generic import CreateView, DetailView, UpdateView, TemplateView
from django.views.generic.edit import FormMixin from django.views.generic.edit import FormMixin
from django_tables2.views import SingleTableView from django_tables2.views import SingleTableView
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from note.forms import ImageForm from note.forms import ImageForm
#from note.forms import AliasForm, ImageForm from note.models import Alias, NoteUser, NoteSpecial
from note.models import Alias, NoteUser from note.models.transactions import Transaction, SpecialTransaction
from note.models.transactions import Transaction
from note.tables import HistoryTable, AliasTable from note.tables import HistoryTable, AliasTable
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin
from .filters import UserFilter, UserFilterFormHelper from .forms import ProfileForm, ClubForm, MembershipForm, CustomAuthenticationForm
from .forms import SignUpForm, ProfileForm, ClubForm, MembershipForm, MemberFormSet, FormSetHelper, \ from .models import Club, Membership, Role
CustomAuthenticationForm from .tables import ClubTable, UserTable, MembershipTable
from .models import Club, Membership
from .tables import ClubTable, UserTable
class CustomLoginView(LoginView): class CustomLoginView(LoginView):
"""
Login view, where the user can select its permission mask.
"""
form_class = CustomAuthenticationForm form_class = CustomAuthenticationForm
def form_valid(self, form): def form_valid(self, form):
@ -42,33 +40,10 @@ class CustomLoginView(LoginView):
return super().form_valid(form) return super().form_valid(form)
class UserCreateView(CreateView): class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
""" """
Une vue pour inscrire un utilisateur et lui créer un profile Update the user information.
""" """
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["profile_form"] = self.second_form()
return context
def form_valid(self, form):
profile_form = ProfileForm(self.request.POST)
if form.is_valid() and profile_form.is_valid():
user = form.save(commit=False)
user.profile = profile_form.save(commit=False)
user.save()
user.profile.save()
return super().form_valid(form)
class UserUpdateView(LoginRequiredMixin, UpdateView):
model = User model = User
fields = ['first_name', 'last_name', 'username', 'email'] fields = ['first_name', 'last_name', 'username', 'email']
template_name = 'member/profile_update.html' template_name = 'member/profile_update.html'
@ -77,14 +52,20 @@ class UserUpdateView(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)
form = context['form']
form.fields['username'].widget.attrs.pop("autofocus", None)
form.fields['first_name'].widget.attrs.update({"autofocus": "autofocus"})
form.fields['first_name'].required = True
form.fields['last_name'].required = True
form.fields['email'].required = True
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)
context['title'] = _("Update Profile") context['title'] = _("Update Profile")
return context return context
def get_form(self, form_class=None): def form_valid(self, form):
form = super().get_form(form_class)
if 'username' not in form.data:
return form
new_username = form.data['username'] new_username = form.data['username']
# Si l'utilisateur cherche à modifier son pseudo, le nouveau pseudo ne doit pas être proche d'un alias existant # Si l'utilisateur cherche à modifier son pseudo, le nouveau pseudo ne doit pas être proche d'un alias existant
note = NoteUser.objects.filter( note = NoteUser.objects.filter(
@ -92,9 +73,8 @@ class UserUpdateView(LoginRequiredMixin, UpdateView):
if note.exists() and note.get().user != self.object: if note.exists() and note.get().user != self.object:
form.add_error('username', form.add_error('username',
_("An alias with a similar name already exists.")) _("An alias with a similar name already exists."))
return form return super().form_invalid(form)
def form_valid(self, form):
profile_form = ProfileForm( profile_form = ProfileForm(
data=self.request.POST, data=self.request.POST,
instance=self.object.profile, instance=self.object.profile,
@ -102,29 +82,35 @@ class UserUpdateView(LoginRequiredMixin, UpdateView):
if form.is_valid() and profile_form.is_valid(): if form.is_valid() and profile_form.is_valid():
new_username = form.data['username'] new_username = form.data['username']
alias = Alias.objects.filter(name=new_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 # Si le nouveau pseudo n'est pas un de nos alias,
# on supprime éventuellement un alias similaire pour le remplacer
if not alias.exists(): if not alias.exists():
similar = Alias.objects.filter( similar = Alias.objects.filter(
normalized_name=Alias.normalize(new_username)) normalized_name=Alias.normalize(new_username))
if similar.exists(): if similar.exists():
similar.delete() similar.delete()
olduser = User.objects.get(pk=form.instance.pk)
user = form.save(commit=False) user = form.save(commit=False)
profile = profile_form.save(commit=False) profile = profile_form.save(commit=False)
profile.user = user profile.user = user
profile.save() profile.save()
user.save() 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.send_email_validation_link()
return super().form_valid(form) return super().form_valid(form)
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
if kwargs: url = 'member:user_detail' if self.object.profile.registration_valid else 'registration:future_user_detail'
return reverse_lazy('member:user_detail', return reverse_lazy(url, args=(self.object.id,))
kwargs={'pk': kwargs['id']})
else:
return reverse_lazy('member:user_detail', args=(self.object.id,))
class UserDetailView(LoginRequiredMixin, DetailView): class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
""" """
Affiche les informations sur un utilisateur, sa note, ses clubs... Affiche les informations sur un utilisateur, sa note, ses clubs...
""" """
@ -133,43 +119,73 @@ class UserDetailView(LoginRequiredMixin, DetailView):
template_name = "member/profile_detail.html" template_name = "member/profile_detail.html"
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
return super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, User, "view")) """
We can't display information of a not registered user.
"""
return super().get_queryset().filter(profile__registration_valid=True)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
user = context['user_object'] user = context['user_object']
history_list = \ history_list = \
Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note)).order_by("-id") Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note)).order_by("-id")\
context['history_list'] = HistoryTable(history_list) .filter(PermissionBackend.filter_queryset(self.request.user, Transaction, "view"))
club_list = \ history_table = HistoryTable(history_list, prefix='transaction-')
Membership.objects.all().filter(user=user).only("club") history_table.paginate(per_page=20, page=self.request.GET.get("transaction-page", 1))
context['club_list'] = ClubTable(club_list) context['history_list'] = history_table
club_list = Membership.objects.filter(user=user, date_end__gte=datetime.today())\
.filter(PermissionBackend.filter_queryset(self.request.user, Membership, "view"))
membership_table = MembershipTable(data=club_list, prefix='membership-')
membership_table.paginate(per_page=10, page=self.request.GET.get("membership-page", 1))
context['club_list'] = membership_table
return context return context
class UserListView(LoginRequiredMixin, SingleTableView): class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
""" """
Affiche la liste des utilisateurs, avec une fonction de recherche statique Display user list, with a search bar
""" """
model = User model = User
table_class = UserTable table_class = UserTable
template_name = 'member/user_list.html' template_name = 'member/user_list.html'
filter_class = UserFilter
formhelper_class = UserFilterFormHelper
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
qs = super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, User, "view")) """
self.filter = self.filter_class(self.request.GET, queryset=qs) Filter the user list with the given pattern.
self.filter.form.helper = self.formhelper_class() """
return self.filter.qs qs = super().get_queryset().filter(profile__registration_valid=True)
if "search" in self.request.GET:
pattern = self.request.GET["search"]
if not pattern:
return qs.none()
qs = qs.filter(
Q(first_name__iregex=pattern)
| Q(last_name__iregex=pattern)
| Q(profile__section__iregex=pattern)
| Q(profile__username__iregex="^" + pattern)
| Q(note__alias__name__iregex="^" + pattern)
| Q(note__alias__normalized_name__iregex=Alias.normalize("^" + pattern))
)
else:
qs = qs.none()
return qs[:20]
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["filter"] = self.filter
context["title"] = _("Search user")
return context return context
class ProfileAliasView(LoginRequiredMixin, DetailView): class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
View and manage user aliases.
"""
model = User model = User
template_name = 'member/profile_alias.html' template_name = 'member/profile_alias.html'
context_object_name = 'user_object' context_object_name = 'user_object'
@ -181,11 +197,14 @@ class ProfileAliasView(LoginRequiredMixin, DetailView):
return context return context
class PictureUpdateView(LoginRequiredMixin, FormMixin, DetailView): class PictureUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin, DetailView):
"""
Update profile picture of the user note.
"""
form_class = ImageForm form_class = ImageForm
def get_context_data(self, *args, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(*args, **kwargs) context = super().get_context_data(**kwargs)
context['form'] = self.form_class(self.request.POST, self.request.FILES) context['form'] = self.form_class(self.request.POST, self.request.FILES)
return context return context
@ -242,8 +261,7 @@ class ManageAuthTokens(LoginRequiredMixin, TemplateView):
template_name = "member/manage_auth_tokens.html" template_name = "member/manage_auth_tokens.html"
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
if 'regenerate' in request.GET and Token.objects.filter( if 'regenerate' in request.GET and Token.objects.filter(user=request.user).exists():
user=request.user).exists():
Token.objects.get(user=self.request.user).delete() Token.objects.get(user=self.request.user).delete()
return redirect(reverse_lazy('member:auth_token') + "?show", return redirect(reverse_lazy('member:auth_token') + "?show",
permanent=True) permanent=True)
@ -252,39 +270,16 @@ class ManageAuthTokens(LoginRequiredMixin, TemplateView):
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['token'] = Token.objects.get_or_create( context['token'] = Token.objects.get_or_create(user=self.request.user)[0]
user=self.request.user)[0]
return context 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.filter(PermissionBackend.filter_queryset(self.request.user, User, "view")).all()
if self.q:
qs = qs.filter(username__regex="^" + self.q)
return qs
# ******************************* # # ******************************* #
# CLUB # # CLUB #
# ******************************* # # ******************************* #
class ClubCreateView(LoginRequiredMixin, CreateView): class ClubCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
""" """
Create Club Create Club
""" """
@ -296,37 +291,60 @@ class ClubCreateView(LoginRequiredMixin, CreateView):
return super().form_valid(form) return super().form_valid(form)
class ClubListView(LoginRequiredMixin, SingleTableView): class ClubListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
""" """
List existing Clubs List existing Clubs
""" """
model = Club model = Club
table_class = ClubTable table_class = ClubTable
def get_queryset(self, **kwargs):
return super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, Club, "view"))
class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
class ClubDetailView(LoginRequiredMixin, DetailView): """
Display details of a club
"""
model = Club model = Club
context_object_name = "club" context_object_name = "club"
def get_queryset(self, **kwargs):
return super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, Club, "view"))
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
club = context["club"] club = context["club"]
club_transactions = \ if PermissionBackend.check_perm(self.request.user, "member.change_club_membership_start", club):
Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note)) club.update_membership_dates()
context['history_list'] = HistoryTable(club_transactions)
club_member = \ club_transactions = Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note))\
Membership.objects.all().filter(club=club) .filter(PermissionBackend.filter_queryset(self.request.user, Transaction, "view")).order_by('-id')
# TODO: consider only valid Membership history_table = HistoryTable(club_transactions, prefix="history-")
context['member_list'] = club_member history_table.paginate(per_page=20, page=self.request.GET.get('history-page', 1))
context['history_list'] = history_table
club_member = Membership.objects.filter(
club=club,
date_end__gte=datetime.today(),
).filter(PermissionBackend.filter_queryset(self.request.user, Membership, "view"))
membership_table = MembershipTable(data=club_member, prefix="membership-")
membership_table.paginate(per_page=20, page=self.request.GET.get('membership-page', 1))
context['member_list'] = membership_table
# Check if the user has the right to create a membership, to display the button.
empty_membership = Membership(
club=club,
user=User.objects.first(),
date_start=datetime.now().date(),
date_end=datetime.now().date(),
fee=0,
)
context["can_add_members"] = PermissionBackend()\
.has_perm(self.request.user, "member.add_membership", empty_membership)
return context return context
class ClubAliasView(LoginRequiredMixin, DetailView):
class ClubAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
Manage aliases of a club.
"""
model = Club model = Club
template_name = 'member/club_alias.html' template_name = 'member/club_alias.html'
context_object_name = 'club' context_object_name = 'club'
@ -338,15 +356,23 @@ class ClubAliasView(LoginRequiredMixin, DetailView):
return context return context
class ClubUpdateView(LoginRequiredMixin, UpdateView): class ClubUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
"""
Update the information of a club.
"""
model = Club model = Club
context_object_name = "club" context_object_name = "club"
form_class = ClubForm form_class = ClubForm
template_name = "member/club_form.html" template_name = "member/club_form.html"
success_url = reverse_lazy("member:club_detail")
def get_success_url(self):
return reverse_lazy("member:club_detail", kwargs={"pk": self.object.pk})
class ClubPictureUpdateView(PictureUpdateView): class ClubPictureUpdateView(PictureUpdateView):
"""
Update the profile picture of a club.
"""
model = Club model = Club
template_name = 'member/club_picture_update.html' template_name = 'member/club_picture_update.html'
context_object_name = 'club' context_object_name = 'club'
@ -355,34 +381,229 @@ class ClubPictureUpdateView(PictureUpdateView):
return reverse_lazy('member:club_detail', kwargs={'pk': self.object.id}) return reverse_lazy('member:club_detail', kwargs={'pk': self.object.id})
class ClubAddMemberView(LoginRequiredMixin, CreateView): class ClubAddMemberView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
"""
Add a membership to a club.
"""
model = Membership model = Membership
form_class = MembershipForm form_class = MembershipForm
template_name = 'member/add_members.html' template_name = 'member/add_members.html'
def get_queryset(self, **kwargs):
return super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, Membership, "view")
| PermissionBackend.filter_queryset(self.request.user, Membership,
"change"))
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
club = Club.objects.get(pk=self.kwargs["pk"])
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['formset'] = MemberFormSet() form = context['form']
context['helper'] = FormSetHelper()
if "club_pk" in self.kwargs:
# We create a new membership.
club = Club.objects.filter(PermissionBackend.filter_queryset(self.request.user, Club, "view"))\
.get(pk=self.kwargs["club_pk"])
form.fields['credit_amount'].initial = club.membership_fee_paid
form.fields['roles'].initial = Role.objects.filter(name="Membre de club").all()
# If the concerned club is the BDE, then we add the option that Société générale pays the membership.
if club.name != "BDE":
del form.fields['soge']
else:
fee = 0
bde = Club.objects.get(name="BDE")
fee += bde.membership_fee_paid
kfet = Club.objects.get(name="Kfet")
fee += kfet.membership_fee_paid
context["total_fee"] = "{:.02f}".format(fee / 100, )
else:
# This is a renewal. Fields can be pre-completed.
old_membership = self.get_queryset().get(pk=self.kwargs["pk"])
club = old_membership.club
user = old_membership.user
form.fields['user'].initial = user
form.fields['user'].disabled = True
form.fields['roles'].initial = old_membership.roles.all()
form.fields['date_start'].initial = old_membership.date_end + timedelta(days=1)
form.fields['credit_amount'].initial = club.membership_fee_paid if user.profile.paid \
else club.membership_fee_unpaid
form.fields['last_name'].initial = user.last_name
form.fields['first_name'].initial = user.first_name
# If this is a renewal of a BDE membership, Société générale can pays, if it is not yet done
if club.name != "BDE" or user.profile.soge:
del form.fields['soge']
else:
fee = 0
bde = Club.objects.get(name="BDE")
fee += bde.membership_fee_paid if user.profile.paid else bde.membership_fee_unpaid
kfet = Club.objects.get(name="Kfet")
fee += kfet.membership_fee_paid if user.profile.paid else kfet.membership_fee_unpaid
context["total_fee"] = "{:.02f}".format(fee / 100, )
context['club'] = club context['club'] = club
context['no_cache'] = True
return context return context
def post(self, request, *args, **kwargs): def form_valid(self, form):
return """
# TODO: Implement POST Create membership, check that all is good, make transactions
# formset = MembershipFormset(request.POST) """
# if formset.is_valid(): # Get the club that is concerned by the membership
# return self.form_valid(formset) if "club_pk" in self.kwargs:
# else: club = Club.objects.filter(PermissionBackend.filter_queryset(self.request.user, Club, "view")) \
# return self.form_invalid(formset) .get(pk=self.kwargs["club_pk"])
user = form.instance.user
else:
old_membership = self.get_queryset().get(pk=self.kwargs["pk"])
club = old_membership.club
user = old_membership.user
def form_valid(self, formset): form.instance.club = club
formset.save()
return super().form_valid(formset) # Get form data
credit_type = form.cleaned_data["credit_type"]
credit_amount = form.cleaned_data["credit_amount"]
last_name = form.cleaned_data["last_name"]
first_name = form.cleaned_data["first_name"]
bank = form.cleaned_data["bank"]
soge = form.cleaned_data["soge"] and not user.profile.soge and club.name == "BDE"
# If Société générale pays, then we auto-fill some data
if soge:
credit_type = NoteSpecial.objects.get(special_type="Virement bancaire")
bde = club
kfet = Club.objects.get(name="Kfet")
if user.profile.paid:
fee = bde.membership_fee_paid + kfet.membership_fee_paid
else:
fee = bde.membership_fee_unpaid + kfet.membership_fee_unpaid
credit_amount = fee
bank = "Société générale"
if credit_type is None:
credit_amount = 0
if user.profile.paid:
fee = club.membership_fee_paid
else:
fee = club.membership_fee_unpaid
if user.note.balance + credit_amount < fee and not Membership.objects.filter(
club__name="Kfet",
user=user,
date_start__lte=datetime.now().date(),
date_end__gte=datetime.now().date(),
).exists():
# Users without a valid Kfet membership can't have a negative balance.
# Club 2 = Kfet (hard-code :'( )
# TODO Send a notification to the user (with a mail?) to tell her/him to credit her/his note
form.add_error('user',
_("This user don't have enough money to join this club, and can't have a negative balance."))
return super().form_invalid(form)
if club.parent_club is not None:
if not Membership.objects.filter(user=form.instance.user, club=club.parent_club).exists():
form.add_error('user', _('User is not a member of the parent club') + ' ' + club.parent_club.name)
return super().form_invalid(form)
if Membership.objects.filter(
user=form.instance.user,
club=club,
date_start__lte=form.instance.date_start,
date_end__gte=form.instance.date_start,
).exists():
form.add_error('user', _('User is already a member of the club'))
return super().form_invalid(form)
if club.membership_start and form.instance.date_start < club.membership_start:
form.add_error('user', _("The membership must start after {:%m-%d-%Y}.")
.format(form.instance.club.membership_start))
return super().form_invalid(form)
if club.membership_end and form.instance.date_start > club.membership_end:
form.add_error('user', _("The membership must begin before {:%m-%d-%Y}.")
.format(form.instance.club.membership_start))
return super().form_invalid(form)
# Now, all is fine, the membership can be created.
# Credit note before the membership is created.
if credit_amount > 0:
if not last_name or not first_name or (not bank and credit_type.special_type == "Chèque"):
if not last_name:
form.add_error('last_name', _("This field is required."))
if not first_name:
form.add_error('first_name', _("This field is required."))
if not bank and credit_type.special_type == "Chèque":
form.add_error('bank', _("This field is required."))
return self.form_invalid(form)
SpecialTransaction.objects.create(
source=credit_type,
destination=user.note,
quantity=1,
amount=credit_amount,
reason="Crédit " + credit_type.special_type + " (Adhésion " + club.name + ")",
last_name=last_name,
first_name=first_name,
bank=bank,
valid=True,
)
# If Société générale pays, then we store the information: the bank can't pay twice to a same person.
if soge:
user.profile.soge = True
user.profile.save()
kfet = Club.objects.get(name="Kfet")
kfet_fee = kfet.membership_fee_paid if user.profile.paid else kfet.membership_fee_unpaid
# Get current membership, to get the end date
old_membership = Membership.objects.filter(
club__name="Kfet",
user=user,
date_start__lte=datetime.today(),
date_end__gte=datetime.today(),
)
membership = Membership.objects.create(
club=kfet,
user=user,
fee=kfet_fee,
date_start=old_membership.get().date_end + timedelta(days=1)
if old_membership.exists() else form.instance.date_start,
)
if old_membership.exists():
membership.roles.set(old_membership.get().roles.all())
else:
membership.roles.add(Role.objects.get(name="Adhérent Kfet"))
membership.save()
return super().form_valid(form)
def get_success_url(self):
return reverse_lazy('member:club_detail', kwargs={'pk': self.object.club.id})
class ClubManageRolesView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
"""
Manage the roles of a user in a club
"""
model = Membership
form_class = MembershipForm
template_name = 'member/add_members.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
club = self.object.club
context['club'] = club
return context
def get_form(self, form_class=None):
form = super().get_form(form_class)
# We don't create a full membership, we only update one field
form.fields['user'].disabled = True
del form.fields['date_start']
del form.fields['credit_type']
del form.fields['credit_amount']
del form.fields['last_name']
del form.fields['first_name']
del form.fields['bank']
return form
def get_success_url(self):
return reverse_lazy('member:club_detail', kwargs={'pk': self.object.club.id})

View File

@ -8,7 +8,7 @@ from polymorphic.admin import PolymorphicChildModelAdmin, \
from .models.notes import Alias, Note, NoteClub, NoteSpecial, NoteUser from .models.notes import Alias, Note, NoteClub, NoteSpecial, NoteUser
from .models.transactions import Transaction, TemplateCategory, TransactionTemplate, \ from .models.transactions import Transaction, TemplateCategory, TransactionTemplate, \
RecurrentTransaction, MembershipTransaction RecurrentTransaction, MembershipTransaction, SpecialTransaction
class AliasInlines(admin.TabularInline): class AliasInlines(admin.TabularInline):
@ -102,7 +102,7 @@ class TransactionAdmin(PolymorphicParentModelAdmin):
""" """
Admin customisation for Transaction Admin customisation for Transaction
""" """
child_models = (RecurrentTransaction, MembershipTransaction) child_models = (RecurrentTransaction, MembershipTransaction, SpecialTransaction)
list_display = ('created_at', 'poly_source', 'poly_destination', list_display = ('created_at', 'poly_source', 'poly_destination',
'quantity', 'amount', 'valid') 'quantity', 'amount', 'valid')
list_filter = ('valid',) list_filter = ('valid',)
@ -138,6 +138,20 @@ class TransactionAdmin(PolymorphicParentModelAdmin):
return [] return []
@admin.register(MembershipTransaction)
class MembershipTransactionAdmin(PolymorphicChildModelAdmin):
"""
Admin customisation for MembershipTransaction
"""
@admin.register(SpecialTransaction)
class SpecialTransactionAdmin(PolymorphicChildModelAdmin):
"""
Admin customisation for SpecialTransaction
"""
@admin.register(TransactionTemplate) @admin.register(TransactionTemplate)
class TransactionTemplateAdmin(admin.ModelAdmin): class TransactionTemplateAdmin(admin.ModelAdmin):
""" """

View File

@ -90,7 +90,7 @@ class NotePolymorphicSerializer(PolymorphicSerializer):
Note: NoteSerializer, Note: NoteSerializer,
NoteUser: NoteUserSerializer, NoteUser: NoteUserSerializer,
NoteClub: NoteClubSerializer, NoteClub: NoteClubSerializer,
NoteSpecial: NoteSpecialSerializer NoteSpecial: NoteSpecialSerializer,
} }
class Meta: class Meta:
@ -177,6 +177,7 @@ class SpecialTransactionSerializer(serializers.ModelSerializer):
fields = '__all__' fields = '__all__'
# noinspection PyUnresolvedReferences
class TransactionPolymorphicSerializer(PolymorphicSerializer): class TransactionPolymorphicSerializer(PolymorphicSerializer):
model_serializer_mapping = { model_serializer_mapping = {
Transaction: TransactionSerializer, Transaction: TransactionSerializer,
@ -185,5 +186,12 @@ class TransactionPolymorphicSerializer(PolymorphicSerializer):
SpecialTransaction: SpecialTransactionSerializer, SpecialTransaction: SpecialTransactionSerializer,
} }
try:
from activity.models import GuestTransaction
from activity.api.serializers import GuestTransactionSerializer
model_serializer_mapping[GuestTransaction] = GuestTransactionSerializer
except ImportError: # Activity app is not loaded
pass
class Meta: class Meta:
model = Transaction model = Transaction

View File

@ -8,7 +8,6 @@ from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework import viewsets 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 .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer,\ from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer,\
@ -25,7 +24,8 @@ class NotePolymorphicViewSet(ReadOnlyProtectedModelViewSet):
""" """
queryset = Note.objects.all() queryset = Note.objects.all()
serializer_class = NotePolymorphicSerializer serializer_class = NotePolymorphicSerializer
filter_backends = [SearchFilter, OrderingFilter] filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_fields = ['polymorphic_ctype', 'is_active', ]
search_fields = ['$alias__normalized_name', '$alias__name', '$polymorphic_ctype__model', ] search_fields = ['$alias__normalized_name', '$alias__name', '$polymorphic_ctype__model', ]
ordering_fields = ['alias__name', 'alias__normalized_name'] ordering_fields = ['alias__name', 'alias__normalized_name']
@ -60,7 +60,7 @@ class AliasViewSet(ReadProtectedModelViewSet):
def get_serializer_class(self): def get_serializer_class(self):
serializer_class = self.serializer_class serializer_class = self.serializer_class
if self.request.method in ['PUT', 'PATCH']: if self.request.method in ['PUT', 'PATCH']:
#alias owner cannot be change once establish # alias owner cannot be change once establish
setattr(serializer_class.Meta, 'read_only_fields', ('note',)) setattr(serializer_class.Meta, 'read_only_fields', ('note',))
return serializer_class return serializer_class
@ -70,7 +70,7 @@ class AliasViewSet(ReadProtectedModelViewSet):
self.perform_destroy(instance) self.perform_destroy(instance)
except ValidationError as e: except ValidationError as e:
print(e) print(e)
return Response({e.code:e.message},status.HTTP_400_BAD_REQUEST) return Response({e.code: e.message}, status.HTTP_400_BAD_REQUEST)
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
def get_queryset(self): def get_queryset(self):

View File

@ -1,12 +1,12 @@
# 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 dal import autocomplete
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from note_kfet.inputs import Autocomplete
from .models import Alias from .models import TransactionTemplate, NoteClub
from .models import TransactionTemplate
class ImageForm(forms.Form): class ImageForm(forms.Form):
@ -31,11 +31,14 @@ class TransactionTemplateForm(forms.ModelForm):
# forward=(forward.Const('TYPE', 'note_type') où TYPE est dans {user, club, special} # forward=(forward.Const('TYPE', 'note_type') où TYPE est dans {user, club, special}
widgets = { widgets = {
'destination': 'destination':
autocomplete.ModelSelect2( Autocomplete(
url='note:note_autocomplete', NoteClub,
attrs={ attrs={
'data-placeholder': 'Note ...', 'api_url': '/api/note/note/',
'data-minimum-input-length': 1, # We don't evaluate the content type at launch because the DB might be not initialized
'api_url_suffix':
lambda: '&polymorphic_ctype=' + str(ContentType.objects.get_for_model(NoteClub).pk),
'placeholder': 'Note ...',
}, },
), ),
} }

View File

@ -242,9 +242,9 @@ class Alias(models.Model):
pass pass
self.normalized_name = normalized_name self.normalized_name = normalized_name
def save(self,*args,**kwargs): def save(self, *args, **kwargs):
self.normalized_name = self.normalize(self.name) self.normalized_name = self.normalize(self.name)
super().save(*args,**kwargs) super().save(*args, **kwargs)
def delete(self, using=None, keep_parents=False): def delete(self, using=None, keep_parents=False):
if self.name == str(self.note): if self.name == str(self.note):

View File

@ -2,7 +2,6 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.db import models from django.db import models
from django.db.models import F
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 _
@ -47,12 +46,14 @@ class TransactionTemplate(models.Model):
unique=True, unique=True,
error_messages={'unique': _("A template with this name already exist")}, error_messages={'unique': _("A template with this name already exist")},
) )
destination = models.ForeignKey( destination = models.ForeignKey(
NoteClub, NoteClub,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+', # no reverse related_name='+', # no reverse
verbose_name=_('destination'), verbose_name=_('destination'),
) )
amount = models.PositiveIntegerField( amount = models.PositiveIntegerField(
verbose_name=_('amount'), verbose_name=_('amount'),
help_text=_('in centimes'), help_text=_('in centimes'),
@ -63,9 +64,12 @@ class TransactionTemplate(models.Model):
verbose_name=_('type'), verbose_name=_('type'),
max_length=31, max_length=31,
) )
display = models.BooleanField( display = models.BooleanField(
default=True, default=True,
verbose_name=_("display"),
) )
description = models.CharField( description = models.CharField(
verbose_name=_('description'), verbose_name=_('description'),
max_length=255, max_length=255,
@ -141,6 +145,7 @@ class Transaction(PolymorphicModel):
max_length=255, max_length=255,
default=None, default=None,
null=True, null=True,
blank=True,
) )
class Meta: class Meta:

View File

@ -2,7 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
def save_user_note(instance, created, raw, **_kwargs): def save_user_note(instance, raw, **_kwargs):
""" """
Hook to create and save a note when an user is updated Hook to create and save a note when an user is updated
""" """
@ -10,10 +10,11 @@ def save_user_note(instance, created, raw, **_kwargs):
# When provisionning data, do not try to autocreate # When provisionning data, do not try to autocreate
return return
if created: if (instance.is_superuser or instance.profile.registration_valid) and instance.is_active:
from .models import NoteUser # Create note only when the registration is validated
NoteUser.objects.create(user=instance) from note.models import NoteUser
instance.note.save() NoteUser.objects.get_or_create(user=instance)
instance.note.save()
def save_club_note(instance, created, raw, **_kwargs): def save_club_note(instance, created, raw, **_kwargs):

View File

@ -106,9 +106,8 @@ DELETE_TEMPLATE = """
class AliasTable(tables.Table): class AliasTable(tables.Table):
class Meta: class Meta:
attrs = { attrs = {
'class': 'class': 'table table condensed table-striped table-hover',
'table table condensed table-striped table-hover', 'id': "alias_table"
'id':"alias_table"
} }
model = Alias model = Alias
fields = ('name',) fields = ('name',)
@ -118,9 +117,9 @@ class AliasTable(tables.Table):
name = tables.Column(attrs={'td': {'class': 'text-center'}}) name = tables.Column(attrs={'td': {'class': 'text-center'}})
delete_col = tables.TemplateColumn(template_code=DELETE_TEMPLATE, delete_col = tables.TemplateColumn(template_code=DELETE_TEMPLATE,
extra_context={"delete_trans": _('delete')}, extra_context={"delete_trans": _('delete')},
attrs={'td': {'class': 'col-sm-1'}}) attrs={'td': {'class': 'col-sm-1'}},
verbose_name=_("Delete"),)
class ButtonTable(tables.Table): class ButtonTable(tables.Table):
@ -136,17 +135,20 @@ class ButtonTable(tables.Table):
} }
model = TransactionTemplate model = TransactionTemplate
exclude = ('id',)
edit = tables.LinkColumn('note:template_update', edit = tables.LinkColumn('note:template_update',
args=[A('pk')], args=[A('pk')],
attrs={'td': {'class': 'col-sm-1'}, attrs={'td': {'class': 'col-sm-1'},
'a': {'class': 'btn btn-sm btn-primary'}}, 'a': {'class': 'btn btn-sm btn-primary'}},
text=_('edit'), text=_('edit'),
accessor='pk') accessor='pk',
verbose_name=_("Edit"),)
delete_col = tables.TemplateColumn(template_code=DELETE_TEMPLATE, delete_col = tables.TemplateColumn(template_code=DELETE_TEMPLATE,
extra_context={"delete_trans": _('delete')}, extra_context={"delete_trans": _('delete')},
attrs={'td': {'class': 'col-sm-1'}}) attrs={'td': {'class': 'col-sm-1'}},
verbose_name=_("Delete"),)
def render_amount(self, value): def render_amount(self, value):
return pretty_money(value) return pretty_money(value)

View File

@ -18,10 +18,5 @@ def pretty_money(value):
) )
def cents_to_euros(value):
return "{:.02f}".format(value / 100) if value else ""
register = template.Library() register = template.Library()
register.filter('pretty_money', pretty_money) register.filter('pretty_money', pretty_money)
register.filter('cents_to_euros', cents_to_euros)

View File

@ -4,7 +4,6 @@
from django.urls import path from django.urls import path
from . import views from . import views
from .models import Note
app_name = 'note' app_name = 'note'
urlpatterns = [ urlpatterns = [
@ -13,7 +12,4 @@ urlpatterns = [
path('buttons/update/<int:pk>/', views.TransactionTemplateUpdateView.as_view(), name='template_update'), path('buttons/update/<int:pk>/', views.TransactionTemplateUpdateView.as_view(), name='template_update'),
path('buttons/', views.TransactionTemplateListView.as_view(), name='template_list'), path('buttons/', views.TransactionTemplateListView.as_view(), name='template_list'),
path('consos/', views.ConsoView.as_view(), name='consos'), path('consos/', views.ConsoView.as_view(), name='consos'),
# API for the note autocompleter
path('note-autocomplete/', views.NoteAutocomplete.as_view(model=Note), name='note_autocomplete'),
] ]

View File

@ -1,23 +1,24 @@
# 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 dal import autocomplete from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, UpdateView from django.views.generic import CreateView, UpdateView
from django_tables2 import SingleTableView from django_tables2 import SingleTableView
from django.urls import reverse_lazy from django.urls import reverse_lazy
from note_kfet.inputs import AmountInput
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin
from .forms import TransactionTemplateForm from .forms import TransactionTemplateForm
from .models import Transaction, TransactionTemplate, Alias, RecurrentTransaction, NoteSpecial from .models import Transaction, TransactionTemplate, RecurrentTransaction, NoteSpecial
from .models.transactions import SpecialTransaction from .models.transactions import SpecialTransaction
from .tables import HistoryTable, ButtonTable from .tables import HistoryTable, ButtonTable
class TransactionCreateView(LoginRequiredMixin, SingleTableView): class TransactionCreateView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
""" """
View for the creation of Transaction between two note which are not :models:`transactions.RecurrentTransaction`. View for the creation of Transaction between two note which are not :models:`transactions.RecurrentTransaction`.
e.g. for donation/transfer between people and clubs or for credit/debit with :models:`note.NoteSpecial` e.g. for donation/transfer between people and clubs or for credit/debit with :models:`note.NoteSpecial`
@ -27,12 +28,9 @@ class TransactionCreateView(LoginRequiredMixin, SingleTableView):
model = Transaction model = Transaction
# Transaction history table # Transaction history table
table_class = HistoryTable table_class = HistoryTable
table_pagination = {"per_page": 50}
def get_queryset(self): def get_queryset(self, **kwargs):
return Transaction.objects.filter(PermissionBackend.filter_queryset( return super().get_queryset(**kwargs).order_by("-id").all()[:20]
self.request.user, Transaction, "view")
).order_by("-id").all()[:50]
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
""" """
@ -40,109 +38,62 @@ class TransactionCreateView(LoginRequiredMixin, SingleTableView):
""" """
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['title'] = _('Transfer money') context['title'] = _('Transfer money')
context['amount_widget'] = AmountInput(attrs={"id": "amount"})
context['polymorphic_ctype'] = ContentType.objects.get_for_model(Transaction).pk context['polymorphic_ctype'] = ContentType.objects.get_for_model(Transaction).pk
context['special_polymorphic_ctype'] = ContentType.objects.get_for_model(SpecialTransaction).pk context['special_polymorphic_ctype'] = ContentType.objects.get_for_model(SpecialTransaction).pk
context['special_types'] = NoteSpecial.objects.order_by("special_type").all() context['special_types'] = NoteSpecial.objects\
.filter(PermissionBackend.filter_queryset(self.request.user, NoteSpecial, "view"))\
.order_by("special_type").all()
# Add a shortcut for entry page for open activities
if "activity" in settings.INSTALLED_APPS:
from activity.models import Activity
context["activities_open"] = Activity.objects.filter(open=True).filter(
PermissionBackend.filter_queryset(self.request.user, Activity, "view")).filter(
PermissionBackend.filter_queryset(self.request.user, Activity, "change")).all()
return context return context
class NoteAutocomplete(autocomplete.Select2QuerySetView): class TransactionTemplateCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
""" """
Auto complete note by aliases. Used in every search field for note Create Transaction template
ex: :view:`ConsoView`, :view:`TransactionCreateView`
"""
def get_queryset(self):
"""
When someone look for an :models:`note.Alias`, a query is sent to the dedicated API.
This function handles the result and return a filtered list of 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):
"""
Show the selected alias and the username associated
<Alias> (aka. <Username> )
"""
# 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):
"""
The value used for the transactions will be the id of the Note.
"""
return str(result.note.pk)
class TransactionTemplateCreateView(LoginRequiredMixin, CreateView):
"""
Create TransactionTemplate
""" """
model = TransactionTemplate model = TransactionTemplate
form_class = TransactionTemplateForm form_class = TransactionTemplateForm
success_url = reverse_lazy('note:template_list') success_url = reverse_lazy('note:template_list')
class TransactionTemplateListView(LoginRequiredMixin, SingleTableView): class TransactionTemplateListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
""" """
List TransactionsTemplates List Transaction templates
""" """
model = TransactionTemplate model = TransactionTemplate
table_class = ButtonTable table_class = ButtonTable
class TransactionTemplateUpdateView(LoginRequiredMixin, UpdateView): class TransactionTemplateUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
""" """
Update Transaction template
""" """
model = TransactionTemplate model = TransactionTemplate
form_class = TransactionTemplateForm form_class = TransactionTemplateForm
success_url = reverse_lazy('note:template_list') success_url = reverse_lazy('note:template_list')
class ConsoView(LoginRequiredMixin, SingleTableView): class ConsoView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
""" """
The Magic View that make people pay their beer and burgers. The Magic View that make people pay their beer and burgers.
(Most of the magic happens in the dark world of Javascript see consos.js) (Most of the magic happens in the dark world of Javascript see consos.js)
""" """
model = Transaction
template_name = "note/conso_form.html" template_name = "note/conso_form.html"
# Transaction history table # Transaction history table
table_class = HistoryTable table_class = HistoryTable
table_pagination = {"per_page": 50}
def get_queryset(self): def get_queryset(self, **kwargs):
return Transaction.objects.filter( return super().get_queryset(**kwargs).order_by("-id").all()[:20]
PermissionBackend.filter_queryset(self.request.user, Transaction, "view")
).order_by("-id").all()[:50]
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
""" """

View File

@ -3,7 +3,7 @@
from rest_framework import serializers from rest_framework import serializers
from ..models import Permission from ..models import Permission, RolePermissions
class PermissionSerializer(serializers.ModelSerializer): class PermissionSerializer(serializers.ModelSerializer):
@ -15,3 +15,14 @@ class PermissionSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Permission model = Permission
fields = '__all__' fields = '__all__'
class RolePermissionsSerializer(serializers.ModelSerializer):
"""
REST API Serializer for RolePermissions types.
The djangorestframework plugin will analyse the model `RolePermissions` and parse all fields in the API.
"""
class Meta:
model = RolePermissions
fields = '__all__'

View File

@ -1,11 +1,12 @@
# 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 .views import PermissionViewSet from .views import PermissionViewSet, RolePermissionsViewSet
def register_permission_urls(router, path): def register_permission_urls(router, path):
""" """
Configure router for permission REST API. Configure router for permission REST API.
""" """
router.register(path, PermissionViewSet) router.register(path + "/permission", PermissionViewSet)
router.register(path + "/roles", RolePermissionsViewSet)

View File

@ -4,17 +4,29 @@
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from api.viewsets import ReadOnlyProtectedModelViewSet from api.viewsets import ReadOnlyProtectedModelViewSet
from .serializers import PermissionSerializer from .serializers import PermissionSerializer, RolePermissionsSerializer
from ..models import Permission from ..models import Permission, RolePermissions
class PermissionViewSet(ReadOnlyProtectedModelViewSet): class PermissionViewSet(ReadOnlyProtectedModelViewSet):
""" """
REST API View set. REST API View set.
The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer, The djangorestframework plugin will get all `Permission` objects, serialize it to JSON with the given serializer,
then render it on /api/logs/ then render it on /api/permission/permission/
""" """
queryset = Permission.objects.all() queryset = Permission.objects.all()
serializer_class = PermissionSerializer serializer_class = PermissionSerializer
filter_backends = [DjangoFilterBackend] filter_backends = [DjangoFilterBackend]
filterset_fields = ['model', 'type', ] filterset_fields = ['model', 'type', ]
class RolePermissionsViewSet(ReadOnlyProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `RolePermission` objects, serialize it to JSON with the given serializer
then render it on /api/permission/roles/
"""
queryset = RolePermissions.objects.all()
serializer_class = RolePermissionsSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['role', ]

View File

@ -1,6 +1,8 @@
# 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 django.contrib.auth.backends import ModelBackend from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import User, AnonymousUser from django.contrib.auth.models import User, AnonymousUser
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@ -9,6 +11,7 @@ from note.models import Note, NoteUser, NoteClub, NoteSpecial
from note_kfet.middlewares import get_current_session from note_kfet.middlewares import get_current_session
from member.models import Membership, Club from member.models import Membership, Club
from .decorators import memoize
from .models import Permission from .models import Permission
@ -20,6 +23,28 @@ class PermissionBackend(ModelBackend):
supports_anonymous_user = False supports_anonymous_user = False
supports_inactive_user = False supports_inactive_user = False
@staticmethod
@memoize
def get_raw_permissions(user, t):
"""
Query permissions of a certain type for a user, then memoize it.
:param user: The owner of the permissions
:param t: The type of the permissions: view, change, add or delete
:return: The queryset of the permissions of the user (memoized) grouped by clubs
"""
if isinstance(user, AnonymousUser):
# Unauthenticated users have no permissions
return Permission.objects.none()
return Permission.objects.annotate(club=F("rolepermissions__role__membership__club")) \
.filter(
rolepermissions__role__membership__user=user,
rolepermissions__role__membership__date_start__lte=datetime.date.today(),
rolepermissions__role__membership__date_end__gte=datetime.date.today(),
type=t,
mask__rank__lte=get_current_session().get("permission_mask", 0),
).distinct()
@staticmethod @staticmethod
def permissions(user, model, type): def permissions(user, model, type):
""" """
@ -29,16 +54,16 @@ class PermissionBackend(ModelBackend):
:param type: The type of the permissions: view, change, add or delete :param type: The type of the permissions: view, change, add or delete
:return: A generator of the requested permissions :return: A generator of the requested permissions
""" """
for permission in Permission.objects.annotate(club=F("rolepermissions__role__membership__club")) \ clubs = {}
.filter(
rolepermissions__role__membership__user=user, for permission in PermissionBackend.get_raw_permissions(user, type):
model__app_label=model.app_label, # For polymorphic models, we don't filter on model type if not isinstance(model.model_class()(), permission.model.model_class()) or not permission.club:
type=type,
).all():
if not isinstance(model, permission.model.__class__):
continue continue
club = Club.objects.get(pk=permission.club) if permission.club not in clubs:
clubs[permission.club] = club = Club.objects.get(pk=permission.club)
else:
club = clubs[permission.club]
permission = permission.about( permission = permission.about(
user=user, user=user,
club=club, club=club,
@ -52,10 +77,10 @@ class PermissionBackend(ModelBackend):
F=F, F=F,
Q=Q Q=Q
) )
if permission.mask.rank <= get_current_session().get("permission_mask", 0): yield permission
yield permission
@staticmethod @staticmethod
@memoize
def filter_queryset(user, model, t, field=None): def filter_queryset(user, model, t, field=None):
""" """
Filter a queryset by considering the permissions of a given user. Filter a queryset by considering the permissions of a given user.
@ -89,10 +114,23 @@ class PermissionBackend(ModelBackend):
query = query | perm.query query = query | perm.query
return query return query
def has_perm(self, user_obj, perm, obj=None): @staticmethod
@memoize
def check_perm(user_obj, perm, obj=None):
"""
Check is the given user has the permission over a given object.
The result is then memoized.
Exception: for add permissions, since the object is not hashable since it doesn't have any
primary key, the result is not memoized. Moreover, the right could change
(e.g. for a transaction, the balance of the user could change)
"""
if user_obj is None or isinstance(user_obj, AnonymousUser): if user_obj is None or isinstance(user_obj, AnonymousUser):
return False return False
sess = get_current_session()
if sess is not None and sess.session_key is None:
return Permission.objects.none()
if user_obj.is_superuser and get_current_session().get("permission_mask", 0) >= 42: if user_obj.is_superuser and get_current_session().get("permission_mask", 0) >= 42:
return True return True
@ -104,10 +142,13 @@ class PermissionBackend(ModelBackend):
perm_field = perm[2] if len(perm) == 3 else None perm_field = perm[2] if len(perm) == 3 else None
ct = ContentType.objects.get_for_model(obj) ct = ContentType.objects.get_for_model(obj)
if any(permission.applies(obj, perm_type, perm_field) if any(permission.applies(obj, perm_type, perm_field)
for permission in self.permissions(user_obj, ct, perm_type)): for permission in PermissionBackend.permissions(user_obj, ct, perm_type)):
return True return True
return False return False
def has_perm(self, user_obj, perm, obj=None):
return PermissionBackend.check_perm(user_obj, perm, obj)
def has_module_perms(self, user_obj, app_label): def has_module_perms(self, user_obj, app_label):
return False return False

View File

@ -0,0 +1,59 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from functools import lru_cache
from time import time
from django.contrib.sessions.models import Session
from note_kfet.middlewares import get_current_session
def memoize(f):
"""
Memoize results and store in sessions
This decorator is useful for permissions: they are loaded once needed, then stored for next calls.
The storage is contained with sessions since it depends on the selected mask.
"""
sess_funs = {}
last_collect = time()
def collect():
"""
Clear cache of results when sessions are invalid, to flush useless data.
This function is called every minute.
"""
nonlocal sess_funs
new_sess_funs = {}
for sess_key in sess_funs:
if Session.objects.filter(session_key=sess_key).exists():
new_sess_funs[sess_key] = sess_funs[sess_key]
sess_funs = new_sess_funs
def func(*args, **kwargs):
nonlocal last_collect
if time() - last_collect > 60:
# Clear cache
collect()
last_collect = time()
# If there is no session, then we don't memoize anything.
sess = get_current_session()
if sess is None or sess.session_key is None:
return f(*args, **kwargs)
sess_key = sess.session_key
if sess_key not in sess_funs:
# lru_cache makes the job of memoization
# We store only the 512 latest data per session. It has to be enough.
sess_funs[sess_key] = lru_cache(512)(f)
try:
return sess_funs[sess_key](*args, **kwargs)
except TypeError: # For add permissions, objects are not hashable (not yet created). Don't memoize this case.
return f(*args, **kwargs)
func.func_name = f.__name__
return func

File diff suppressed because it is too large Load Diff

View File

@ -38,20 +38,33 @@ class InstancedPermission:
if permission_type == self.type: if permission_type == self.type:
self.update_query() self.update_query()
# Don't increase indexes # Don't increase indexes, if the primary key is an AutoField
obj.pk = 0 if not hasattr(obj, "pk") or not obj.pk:
obj.pk = 0
oldpk = None
else:
oldpk = obj.pk
# Ensure previous models are deleted
self.model.model_class().objects.filter(pk=obj.pk).annotate(_force_delete=F("pk")).delete()
# Force insertion, no data verification, no trigger # Force insertion, no data verification, no trigger
obj._force_save = True
Model.save(obj, force_insert=True) Model.save(obj, force_insert=True)
ret = obj in self.model.model_class().objects.filter(self.query).all() # We don't want log anything
obj._no_log = True
ret = self.model.model_class().objects.filter(self.query & Q(pk=obj.pk)).exists()
# Delete testing object # Delete testing object
obj._force_delete = True
Model.delete(obj) Model.delete(obj)
# If the primary key was specified, we restore it
obj.pk = oldpk
return ret return ret
if permission_type == self.type: if permission_type == self.type:
if self.field and field_name != self.field: if self.field and field_name != self.field:
return False return False
self.update_query() self.update_query()
return obj in self.model.model_class().objects.filter(self.query).all() return self.model.model_class().objects.filter(self.query & Q(pk=obj.pk)).exists()
else: else:
return False return False
@ -93,6 +106,10 @@ class PermissionMask(models.Model):
def __str__(self): def __str__(self):
return self.description return self.description
class Meta:
verbose_name = _("permission mask")
verbose_name_plural = _("permission masks")
class Permission(models.Model): class Permission(models.Model):
@ -140,6 +157,8 @@ class Permission(models.Model):
class Meta: class Meta:
unique_together = ('model', 'query', 'type', 'field') unique_together = ('model', 'query', 'type', 'field')
verbose_name = _("permission")
verbose_name_plural = _("permissions")
def clean(self): def clean(self):
self.query = json.dumps(json.loads(self.query)) self.query = json.dumps(json.loads(self.query))
@ -280,3 +299,7 @@ class RolePermissions(models.Model):
def __str__(self): def __str__(self):
return str(self.role) return str(self.role)
class Meta:
verbose_name = _("role permissions")
verbose_name_plural = _("role permissions")

View File

@ -44,7 +44,7 @@ class StrongDjangoObjectPermissions(DjangoObjectPermissions):
perms = self.get_required_object_permissions(request.method, model_cls) perms = self.get_required_object_permissions(request.method, model_cls)
# if not user.has_perms(perms, obj): # if not user.has_perms(perms, obj):
if not all(PermissionBackend().has_perm(user, perm, obj) for perm in perms): if not all(PermissionBackend.check_perm(user, perm, obj) for perm in perms):
# If the user does not have permissions we need to determine if # If the user does not have permissions we need to determine if
# they have read permissions to see 403, or not, and simply see # they have read permissions to see 403, or not, and simply see
# a 404 response. # a 404 response.

View File

@ -2,8 +2,6 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.models.signals import pre_save, pre_delete, post_save, post_delete
from logs import signals as logs_signals
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
@ -29,6 +27,9 @@ def pre_save_object(sender, instance, **kwargs):
if instance._meta.label_lower in EXCLUDED: if instance._meta.label_lower in EXCLUDED:
return return
if hasattr(instance, "_force_save"):
return
user = get_current_authenticated_user() user = get_current_authenticated_user()
if user is None: if user is None:
# Action performed on shell is always granted # Action performed on shell is always granted
@ -43,7 +44,7 @@ def pre_save_object(sender, instance, **kwargs):
# We check if the user can change the model # We check if the user can change the model
# If the user has all right on a model, then OK # If the user has all right on a model, then OK
if PermissionBackend().has_perm(user, app_label + ".change_" + model_name, instance): if PermissionBackend.check_perm(user, app_label + ".change_" + model_name, instance):
return return
# In the other case, we check if he/she has the right to change one field # In the other case, we check if he/she has the right to change one field
@ -55,35 +56,17 @@ def pre_save_object(sender, instance, **kwargs):
# If the field wasn't modified, no need to check the permissions # If the field wasn't modified, no need to check the permissions
if old_value == new_value: if old_value == new_value:
continue continue
if not PermissionBackend().has_perm(user, app_label + ".change_" + model_name + "_" + field_name, instance): if not PermissionBackend.check_perm(user, app_label + ".change_" + model_name + "_" + field_name, instance):
raise PermissionDenied raise PermissionDenied
else: else:
# We check if the user can add the model
# While checking permissions, the object will be inserted in the DB, then removed.
# We disable temporary the connectors
pre_save.disconnect(pre_save_object)
pre_delete.disconnect(pre_delete_object)
# We disable also logs connectors
pre_save.disconnect(logs_signals.pre_save_object)
post_save.disconnect(logs_signals.save_object)
post_delete.disconnect(logs_signals.delete_object)
# We check if the user has right to add the object # We check if the user has right to add the object
has_perm = PermissionBackend().has_perm(user, app_label + ".add_" + model_name, instance) has_perm = PermissionBackend.check_perm(user, app_label + ".add_" + model_name, instance)
# Then we reconnect all
pre_save.connect(pre_save_object)
pre_delete.connect(pre_delete_object)
pre_save.connect(logs_signals.pre_save_object)
post_save.connect(logs_signals.save_object)
post_delete.connect(logs_signals.delete_object)
if not has_perm: if not has_perm:
raise PermissionDenied raise PermissionDenied
def pre_delete_object(sender, instance, **kwargs): def pre_delete_object(instance, **kwargs):
""" """
Before a model get deleted, we check the permissions Before a model get deleted, we check the permissions
""" """
@ -91,6 +74,9 @@ def pre_delete_object(sender, instance, **kwargs):
if instance._meta.label_lower in EXCLUDED: if instance._meta.label_lower in EXCLUDED:
return return
if hasattr(instance, "_force_delete"):
return
user = get_current_authenticated_user() user = get_current_authenticated_user()
if user is None: if user is None:
# Action performed on shell is always granted # Action performed on shell is always granted
@ -101,5 +87,5 @@ def pre_delete_object(sender, instance, **kwargs):
model_name = model_name_full[1] model_name = model_name_full[1]
# We check if the user has rights to delete the object # We check if the user has rights to delete the object
if not PermissionBackend().has_perm(user, app_label + ".delete_" + model_name, instance): if not PermissionBackend.check_perm(user, app_label + ".delete_" + model_name, instance):
raise PermissionDenied raise PermissionDenied

View File

@ -4,6 +4,7 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.template.defaultfilters import stringfilter from django.template.defaultfilters import stringfilter
from django import template from django import template
from note.models import Transaction
from note_kfet.middlewares import get_current_authenticated_user, get_current_session from note_kfet.middlewares import get_current_authenticated_user, get_current_session
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
@ -19,13 +20,8 @@ def not_empty_model_list(model_name):
return False return False
elif user.is_superuser and session.get("permission_mask", 0) >= 42: elif user.is_superuser and session.get("permission_mask", 0) >= 42:
return True return True
if session.get("not_empty_model_list_" + model_name, None): qs = model_list(model_name)
return session.get("not_empty_model_list_" + model_name, None) == 1 return qs.exists()
spl = model_name.split(".")
ct = ContentType.objects.get(app_label=spl[0], model=spl[1])
qs = ct.model_class().objects.filter(PermissionBackend.filter_queryset(user, ct, "view")).all()
session["not_empty_model_list_" + model_name] = 1 if qs.exists() else 2
return session.get("not_empty_model_list_" + model_name) == 1
@stringfilter @stringfilter
@ -39,15 +35,54 @@ def not_empty_model_change_list(model_name):
return False return False
elif user.is_superuser and session.get("permission_mask", 0) >= 42: elif user.is_superuser and session.get("permission_mask", 0) >= 42:
return True return True
if session.get("not_empty_model_change_list_" + model_name, None): qs = model_list(model_name, "change")
return session.get("not_empty_model_change_list_" + model_name, None) == 1 return qs.exists()
@stringfilter
def model_list(model_name, t="view"):
"""
Return the queryset of all visible instances of the given model.
"""
user = get_current_authenticated_user()
if user is None:
return False
spl = model_name.split(".") spl = model_name.split(".")
ct = ContentType.objects.get(app_label=spl[0], model=spl[1]) ct = ContentType.objects.get(app_label=spl[0], model=spl[1])
qs = ct.model_class().objects.filter(PermissionBackend.filter_queryset(user, ct, "change")) qs = ct.model_class().objects.filter(PermissionBackend.filter_queryset(user, ct, t)).all()
session["not_empty_model_change_list_" + model_name] = 1 if qs.exists() else 2 return qs
return session.get("not_empty_model_change_list_" + model_name) == 1
def has_perm(perm, obj):
return PermissionBackend.check_perm(get_current_authenticated_user(), perm, obj)
def can_create_transaction():
"""
:return: True iff the authenticated user can create a transaction.
"""
user = get_current_authenticated_user()
session = get_current_session()
if user is None:
return False
elif user.is_superuser and session.get("permission_mask", 0) >= 42:
return True
if session.get("can_create_transaction", None):
return session.get("can_create_transaction", None) == 1
empty_transaction = Transaction(
source=user.note,
destination=user.note,
quantity=1,
amount=0,
reason="Check permissions",
)
session["can_create_transaction"] = PermissionBackend.check_perm(user, "note.add_transaction", empty_transaction)
return session.get("can_create_transaction") == 1
register = template.Library() register = template.Library()
register.filter('not_empty_model_list', not_empty_model_list) register.filter('not_empty_model_list', not_empty_model_list)
register.filter('not_empty_model_change_list', not_empty_model_change_list) register.filter('not_empty_model_change_list', not_empty_model_change_list)
register.filter('model_list', model_list)
register.filter('has_perm', has_perm)

11
apps/permission/views.py Normal file
View File

@ -0,0 +1,11 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from permission.backends import PermissionBackend
class ProtectQuerysetMixin:
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs)
return qs.filter(PermissionBackend.filter_queryset(self.request.user, qs.model, "view"))

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 = 'registration.apps.RegistrationConfig'

10
apps/registration/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 RegistrationConfig(AppConfig):
name = 'registration'
verbose_name = _('registration')

View File

@ -0,0 +1,80 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User
from django.utils.translation import gettext_lazy as _
from note.models import NoteSpecial
from note_kfet.inputs import AmountInput
class SignUpForm(UserCreationForm):
"""
Pre-register users with all information
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['username'].widget.attrs.pop("autofocus", None)
self.fields['first_name'].widget.attrs.update({"autofocus": "autofocus"})
self.fields['first_name'].required = True
self.fields['last_name'].required = True
self.fields['email'].required = True
self.fields['email'].help_text = _("This address must be valid.")
class Meta:
model = User
fields = ('first_name', 'last_name', 'username', 'email', )
class ValidationForm(forms.Form):
"""
Validate the inscription of the new users and pay memberships.
"""
soge = forms.BooleanField(
label=_("Inscription paid by Société Générale"),
required=False,
help_text=_("Check this case is the Société Générale paid the inscription."),
)
credit_type = forms.ModelChoiceField(
queryset=NoteSpecial.objects,
label=_("Credit type"),
empty_label=_("No credit"),
required=False,
)
credit_amount = forms.IntegerField(
label=_("Credit amount"),
required=False,
initial=0,
widget=AmountInput(),
)
last_name = forms.CharField(
label=_("Last name"),
required=False,
)
first_name = forms.CharField(
label=_("First name"),
required=False,
)
bank = forms.CharField(
label=_("Bank"),
required=False,
)
join_BDE = forms.BooleanField(
label=_("Join BDE Club"),
required=False,
initial=True,
)
# The user can join the Kfet club at the inscription
join_Kfet = forms.BooleanField(
label=_("Join Kfet Club"),
required=False,
initial=True,
)

View File

View File

@ -0,0 +1,26 @@
# 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
class FutureUserTable(tables.Table):
"""
Display the list of pre-registered users
"""
phone_number = tables.Column(accessor='profile.phone_number')
section = tables.Column(accessor='profile.section')
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
row_attrs = {
'class': 'table-row',
'data-href': lambda record: record.pk
}

View File

@ -0,0 +1,30 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
# Copied from https://gitlab.crans.org/bombar/codeflix/-/blob/master/codeflix/codeflix/tokens.py
from django.contrib.auth.tokens import PasswordResetTokenGenerator
class AccountActivationTokenGenerator(PasswordResetTokenGenerator):
"""
Create a unique token generator to confirm email addresses.
"""
def _make_hash_value(self, user, timestamp):
"""
Hash the user's primary key and some user state that's sure to change
after an account validation to produce a token that invalidated when
it's used:
1. The user.profile.email_confirmed field will change upon an account
validation.
2. The last_login field will usually be updated very shortly after
an account validation.
Failing those things, settings.PASSWORD_RESET_TIMEOUT_DAYS eventually
invalidates the token.
"""
# Truncate microseconds so that tokens are consistent even if the
# database doesn't support microseconds.
login_timestamp = '' if user.last_login is None else user.last_login.replace(microsecond=0, tzinfo=None)
return str(user.pk) + str(user.profile.email_confirmed) + str(login_timestamp) + str(timestamp)
email_validation_token = AccountActivationTokenGenerator()

18
apps/registration/urls.py Normal file
View File

@ -0,0 +1,18 @@
# 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
app_name = 'registration'
urlpatterns = [
path('signup/', views.UserCreateView.as_view(), name="signup"),
path('validate_email/sent/', views.UserValidationEmailSentView.as_view(), name='email_validation_sent'),
path('validate_email/resend/<int:pk>/', views.UserResendValidationEmailView.as_view(),
name='email_validation_resend'),
path('validate_email/<uidb64>/<token>/', views.UserValidateView.as_view(), name='email_validation'),
path('validate_user/', views.FutureUserListView.as_view(), name="future_user_list"),
path('validate_user/<int:pk>/', views.FutureUserDetailView.as_view(), name="future_user_detail"),
path('validate_user/<int:pk>/invalidate/', views.FutureUserInvalidateView.as_view(), name="future_user_invalidate"),
]

358
apps/registration/views.py Normal file
View File

@ -0,0 +1,358 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.shortcuts import resolve_url, redirect
from django.urls import reverse_lazy
from django.utils.http import urlsafe_base64_decode
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.generic import CreateView, TemplateView, DetailView, FormView
from django.views.generic.edit import FormMixin
from django_tables2 import SingleTableView
from member.forms import ProfileForm
from member.models import Membership, Club, Role
from note.models import SpecialTransaction, NoteSpecial
from note.templatetags.pretty_money import pretty_money
from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin
from .forms import SignUpForm, ValidationForm
from .tables import FutureUserTable
from .tokens import email_validation_token
class UserCreateView(CreateView):
"""
Une vue pour inscrire un utilisateur et lui créer un profil
"""
form_class = SignUpForm
success_url = reverse_lazy('registration:email_validation_sent')
template_name = 'registration/signup.html'
second_form = ProfileForm
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["profile_form"] = self.second_form()
return context
def form_valid(self, form):
"""
If the form is valid, then the user is created with is_active set to False
so that the user cannot log in until the email has been validated.
The user must also wait that someone validate her/his account.
"""
profile_form = ProfileForm(data=self.request.POST)
if not profile_form.is_valid():
return self.form_invalid(form)
# Save the user and the profile
user = form.save(commit=False)
user.is_active = False
profile_form.instance.user = user
profile = profile_form.save(commit=False)
user.profile = profile
user.save()
user.refresh_from_db()
profile.user = user
profile.save()
user.profile.send_email_validation_link()
return super().form_valid(form)
class UserValidateView(TemplateView):
"""
A view to validate the email address.
"""
title = _("Email validation")
template_name = 'registration/email_validation_complete.html'
def get(self, *args, **kwargs):
"""
With a given token and user id (in params), validate the email address.
"""
assert 'uidb64' in kwargs and 'token' in kwargs
self.validlink = False
user = self.get_user(kwargs['uidb64'])
token = kwargs['token']
# Validate the token
if user is not None and email_validation_token.check_token(user, token):
self.validlink = True
# The user must wait that someone validates the account before the user can be active and login.
user.is_active = user.profile.registration_valid or user.is_superuser
user.profile.email_confirmed = True
user.save()
user.profile.save()
return super().dispatch(*args, **kwargs)
else:
# Display the "Email validation unsuccessful" page.
return self.render_to_response(self.get_context_data())
def get_user(self, uidb64):
"""
Get user from the base64-encoded string.
"""
try:
# urlsafe_base64_decode() decodes to bytestring
uid = urlsafe_base64_decode(uidb64).decode()
user = User.objects.get(pk=uid)
except (TypeError, ValueError, OverflowError, User.DoesNotExist, ValidationError):
user = None
return user
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['user'] = self.get_user(self.kwargs["uidb64"])
context['login_url'] = resolve_url(settings.LOGIN_URL)
if self.validlink:
context['validlink'] = True
else:
context.update({
'title': _('Email validation unsuccessful'),
'validlink': False,
})
return context
class UserValidationEmailSentView(TemplateView):
"""
Display the information that the validation link has been sent.
"""
template_name = 'registration/email_validation_email_sent.html'
title = _('Email validation email sent')
class UserResendValidationEmailView(LoginRequiredMixin, ProtectQuerysetMixin, DetailView):
"""
Rensend the email validation link.
"""
model = User
def get(self, request, *args, **kwargs):
user = self.get_object()
user.profile.send_email_validation_link()
url = 'member:user_detail' if user.profile.registration_valid else 'registration:future_user_detail'
return redirect(url, user.id)
class FutureUserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
"""
Display pre-registered users, with a search bar
"""
model = User
table_class = FutureUserTable
template_name = 'registration/future_user_list.html'
def get_queryset(self, **kwargs):
"""
Filter the table with the given parameter.
:param kwargs:
:return:
"""
qs = super().get_queryset().filter(profile__registration_valid=False)
if "search" in self.request.GET:
pattern = self.request.GET["search"]
if not pattern:
return qs.none()
qs = qs.filter(
Q(first_name__iregex=pattern)
| Q(last_name__iregex=pattern)
| Q(profile__section__iregex=pattern)
| Q(username__iregex="^" + pattern)
)
else:
qs = qs.none()
return qs[:20]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["title"] = _("Unregistered users")
return context
class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin, DetailView):
"""
Display information about a pre-registered user, in order to complete the registration.
"""
model = User
form_class = ValidationForm
context_object_name = "user_object"
template_name = "registration/future_profile_detail.html"
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:
return self.form_invalid(form)
def get_queryset(self, **kwargs):
"""
We only display information of a not registered user.
"""
return super().get_queryset().filter(profile__registration_valid=False)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
user = self.get_object()
fee = 0
bde = Club.objects.get(name="BDE")
fee += bde.membership_fee_paid if user.profile.paid else bde.membership_fee_unpaid
kfet = Club.objects.get(name="Kfet")
fee += kfet.membership_fee_paid if user.profile.paid else kfet.membership_fee_unpaid
ctx["total_fee"] = "{:.02f}".format(fee / 100, )
return ctx
def get_form(self, form_class=None):
form = super().get_form(form_class)
user = self.get_object()
form.fields["last_name"].initial = user.last_name
form.fields["first_name"].initial = user.first_name
return form
def form_valid(self, form):
user = self.get_object()
# Get form data
soge = form.cleaned_data["soge"]
credit_type = form.cleaned_data["credit_type"]
credit_amount = form.cleaned_data["credit_amount"]
last_name = form.cleaned_data["last_name"]
first_name = form.cleaned_data["first_name"]
bank = form.cleaned_data["bank"]
join_BDE = form.cleaned_data["join_BDE"]
join_Kfet = form.cleaned_data["join_Kfet"]
if soge:
# If Société Générale pays the inscription, the user joins the two clubs
join_BDE = True
join_Kfet = True
if not join_BDE:
form.add_error('join_BDE', _("You must join the BDE."))
return super().form_invalid(form)
fee = 0
bde = Club.objects.get(name="BDE")
bde_fee = bde.membership_fee_paid if user.profile.paid else bde.membership_fee_unpaid
if join_BDE:
fee += bde_fee
kfet = Club.objects.get(name="Kfet")
kfet_fee = kfet.membership_fee_paid if user.profile.paid else kfet.membership_fee_unpaid
if join_Kfet:
fee += kfet_fee
if soge:
# Fill payment information if Société Générale pays the inscription
credit_type = NoteSpecial.objects.get(special_type="Virement bancaire")
credit_amount = fee
bank = "Société générale"
print("OK")
if join_Kfet and not join_BDE:
form.add_error('join_Kfet', _("You must join BDE club before joining Kfet club."))
if fee > credit_amount:
# Check if the user credits enough money
form.add_error('credit_type',
_("The entered amount is not enough for the memberships, should be at least {}")
.format(pretty_money(fee)))
return self.form_invalid(form)
if credit_type is not None and credit_amount > 0:
if not last_name or not first_name or (not bank and credit_type.special_type == "Chèque"):
if not last_name:
form.add_error('last_name', _("This field is required."))
if not first_name:
form.add_error('first_name', _("This field is required."))
if not bank and credit_type.special_type == "Chèque":
form.add_error('bank', _("This field is required."))
return self.form_invalid(form)
# Save the user and finally validate the registration
# Saving the user creates the associated note
ret = super().form_valid(form)
user.is_active = user.profile.email_confirmed or user.is_superuser
user.profile.registration_valid = True
# Store if Société générale paid for next years
user.profile.soge = soge
user.save()
user.profile.save()
if credit_type is not None and credit_amount > 0:
# Credit the note
SpecialTransaction.objects.create(
source=credit_type,
destination=user.note,
quantity=1,
amount=credit_amount,
reason="Crédit " + ("Société générale" if soge else credit_type.special_type) + " (Inscription)",
last_name=last_name,
first_name=first_name,
bank=bank,
valid=True,
)
if join_BDE:
# Create membership for the user to the BDE starting today
membership = Membership.objects.create(
club=bde,
user=user,
fee=bde_fee,
)
membership.roles.add(Role.objects.get(name="Adhérent BDE"))
membership.save()
if join_Kfet:
# Create membership for the user to the Kfet starting today
membership = Membership.objects.create(
club=kfet,
user=user,
fee=kfet_fee,
)
membership.roles.add(Role.objects.get(name="Adhérent Kfet"))
membership.save()
return ret
def get_success_url(self):
return reverse_lazy('member:user_detail', args=(self.get_object().pk, ))
class FutureUserInvalidateView(ProtectQuerysetMixin, LoginRequiredMixin, View):
"""
Delete a pre-registered user.
"""
def get(self, request, *args, **kwargs):
"""
Delete the pre-registered user which id is given in the URL.
"""
user = User.objects.filter(profile__registration_valid=False)\
.filter(PermissionBackend.filter_queryset(request.user, User, "change", "is_valid"))\
.get(pk=self.kwargs["pk"])
user.delete()
return redirect('registration:future_user_list')

View File

@ -7,6 +7,8 @@ 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.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from note_kfet.inputs import DatePickerInput, AmountInput
from permission.backends import PermissionBackend
from .models import Invoice, Product, Remittance, SpecialTransactionProxy from .models import Invoice, Product, Remittance, SpecialTransactionProxy
@ -19,7 +21,7 @@ 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=datetime.date.today,
widget=forms.TextInput(attrs={'type': 'date'}) widget=DatePickerInput()
) )
def clean_date(self): def clean_date(self):
@ -30,19 +32,28 @@ class InvoiceForm(forms.ModelForm):
exclude = ('bde', ) exclude = ('bde', )
class ProductForm(forms.ModelForm):
class Meta:
model = Product
fields = '__all__'
widgets = {
"amount": AmountInput()
}
# Add a subform per product in the invoice form, and manage correctly the link between the invoice and # Add a subform per product in the invoice form, and manage correctly the link between the invoice and
# its products. The FormSet will search automatically the ForeignKey in the Product model. # its products. The FormSet will search automatically the ForeignKey in the Product model.
ProductFormSet = forms.inlineformset_factory( ProductFormSet = forms.inlineformset_factory(
Invoice, Invoice,
Product, Product,
fields='__all__', form=ProductForm,
extra=1, extra=1,
) )
class ProductFormSetHelper(FormHelper): class ProductFormSetHelper(FormHelper):
""" """
Specify some template informations for the product form. Specify some template information for the product form.
""" """
def __init__(self, form=None): def __init__(self, form=None):
@ -121,7 +132,8 @@ class LinkTransactionToRemittanceForm(forms.ModelForm):
# Add submit button # Add submit button
self.helper.add_input(Submit('submit', _("Submit"), attr={'class': 'btn btn-block btn-primary'})) self.helper.add_input(Submit('submit', _("Submit"), attr={'class': 'btn btn-block btn-primary'}))
self.fields["remittance"].queryset = Remittance.objects.filter(closed=False) self.fields["remittance"].queryset = Remittance.objects.filter(closed=False)\
.filter(PermissionBackend.filter_queryset(self.request.user, Remittance, "view"))
def clean_last_name(self): def clean_last_name(self):
""" """

View File

@ -59,6 +59,10 @@ class Invoice(models.Model):
verbose_name=_("Acquitted"), verbose_name=_("Acquitted"),
) )
class Meta:
verbose_name = _("invoice")
verbose_name_plural = _("invoices")
class Product(models.Model): class Product(models.Model):
""" """
@ -95,6 +99,10 @@ class Product(models.Model):
def total_euros(self): def total_euros(self):
return self.total / 100 return self.total / 100
class Meta:
verbose_name = _("product")
verbose_name_plural = _("products")
class RemittanceType(models.Model): class RemittanceType(models.Model):
""" """
@ -109,6 +117,10 @@ class RemittanceType(models.Model):
def __str__(self): def __str__(self):
return str(self.note) return str(self.note)
class Meta:
verbose_name = _("remittance type")
verbose_name_plural = _("remittance types")
class Remittance(models.Model): class Remittance(models.Model):
""" """
@ -136,6 +148,10 @@ class Remittance(models.Model):
verbose_name=_("Closed"), verbose_name=_("Closed"),
) )
class Meta:
verbose_name = _("remittance")
verbose_name_plural = _("remittances")
@property @property
def transactions(self): def transactions(self):
""" """
@ -187,3 +203,7 @@ class SpecialTransactionProxy(models.Model):
null=True, null=True,
verbose_name=_("Remittance"), verbose_name=_("Remittance"),
) )
class Meta:
verbose_name = _("special transaction proxy")
verbose_name_plural = _("special transaction proxies")

View File

@ -19,13 +19,15 @@ from django.views.generic.base import View, TemplateView
from django_tables2 import SingleTableView from django_tables2 import SingleTableView
from note.models import SpecialTransaction, NoteSpecial from note.models import SpecialTransaction, NoteSpecial
from note_kfet.settings.base import BASE_DIR from note_kfet.settings.base import BASE_DIR
from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin
from .forms import InvoiceForm, ProductFormSet, ProductFormSetHelper, RemittanceForm, LinkTransactionToRemittanceForm from .forms import InvoiceForm, ProductFormSet, ProductFormSetHelper, RemittanceForm, LinkTransactionToRemittanceForm
from .models import Invoice, Product, Remittance, SpecialTransactionProxy from .models import Invoice, Product, Remittance, SpecialTransactionProxy
from .tables import InvoiceTable, RemittanceTable, SpecialTransactionTable from .tables import InvoiceTable, RemittanceTable, SpecialTransactionTable
class InvoiceCreateView(LoginRequiredMixin, CreateView): class InvoiceCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
""" """
Create Invoice Create Invoice
""" """
@ -50,18 +52,8 @@ class InvoiceCreateView(LoginRequiredMixin, CreateView):
def form_valid(self, form): def form_valid(self, form):
ret = super().form_valid(form) ret = super().form_valid(form)
kwargs = {}
# The user type amounts in cents. We convert it in euros.
for key in self.request.POST:
value = self.request.POST[key]
if key.endswith("amount") and value:
kwargs[key] = str(int(100 * float(value)))
elif value:
kwargs[key] = value
# For each product, we save it # For each product, we save it
formset = ProductFormSet(kwargs, instance=form.instance) formset = ProductFormSet(self.request.POST, instance=form.instance)
if formset.is_valid(): if formset.is_valid():
for f in formset: for f in formset:
# We don't save the product if the designation is not entered, ie. if the line is empty # We don't save the product if the designation is not entered, ie. if the line is empty
@ -77,7 +69,7 @@ class InvoiceCreateView(LoginRequiredMixin, CreateView):
return reverse_lazy('treasury:invoice_list') return reverse_lazy('treasury:invoice_list')
class InvoiceListView(LoginRequiredMixin, SingleTableView): class InvoiceListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
""" """
List existing Invoices List existing Invoices
""" """
@ -85,7 +77,7 @@ class InvoiceListView(LoginRequiredMixin, SingleTableView):
table_class = InvoiceTable table_class = InvoiceTable
class InvoiceUpdateView(LoginRequiredMixin, UpdateView): class InvoiceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
""" """
Create Invoice Create Invoice
""" """
@ -112,16 +104,7 @@ class InvoiceUpdateView(LoginRequiredMixin, UpdateView):
def form_valid(self, form): def form_valid(self, form):
ret = super().form_valid(form) ret = super().form_valid(form)
kwargs = {} formset = ProductFormSet(self.request.POST, instance=form.instance)
# The user type amounts in cents. We convert it in euros.
for key in self.request.POST:
value = self.request.POST[key]
if key.endswith("amount") and value:
kwargs[key] = str(int(100 * float(value)))
elif value:
kwargs[key] = value
formset = ProductFormSet(kwargs, instance=form.instance)
saved = [] saved = []
# For each product, we save it # For each product, we save it
if formset.is_valid(): if formset.is_valid():
@ -149,7 +132,7 @@ class InvoiceRenderView(LoginRequiredMixin, View):
def get(self, request, **kwargs): def get(self, request, **kwargs):
pk = kwargs["pk"] pk = kwargs["pk"]
invoice = Invoice.objects.get(pk=pk) invoice = Invoice.objects.filter(PermissionBackend.filter_queryset(request.user, Invoice, "view")).get(pk=pk)
products = Product.objects.filter(invoice=invoice).all() products = Product.objects.filter(invoice=invoice).all()
# Informations of the BDE. Should be updated when the school will move. # Informations of the BDE. Should be updated when the school will move.
@ -207,7 +190,7 @@ class InvoiceRenderView(LoginRequiredMixin, View):
return response return response
class RemittanceCreateView(LoginRequiredMixin, CreateView): class RemittanceCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
""" """
Create Remittance Create Remittance
""" """
@ -218,12 +201,14 @@ class RemittanceCreateView(LoginRequiredMixin, CreateView):
return reverse_lazy('treasury:remittance_list') return reverse_lazy('treasury:remittance_list')
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
ctx["table"] = RemittanceTable(data=Remittance.objects.all()) context["table"] = RemittanceTable(data=Remittance.objects
ctx["special_transactions"] = SpecialTransactionTable(data=SpecialTransaction.objects.none()) .filter(PermissionBackend.filter_queryset(self.request.user, Remittance, "view"))
.all())
context["special_transactions"] = SpecialTransactionTable(data=SpecialTransaction.objects.none())
return ctx return context
class RemittanceListView(LoginRequiredMixin, TemplateView): class RemittanceListView(LoginRequiredMixin, TemplateView):
@ -233,24 +218,30 @@ class RemittanceListView(LoginRequiredMixin, TemplateView):
template_name = "treasury/remittance_list.html" template_name = "treasury/remittance_list.html"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
ctx["opened_remittances"] = RemittanceTable(data=Remittance.objects.filter(closed=False).all()) context["opened_remittances"] = RemittanceTable(
ctx["closed_remittances"] = RemittanceTable(data=Remittance.objects.filter(closed=True).reverse().all()) data=Remittance.objects.filter(closed=False).filter(
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all())
context["closed_remittances"] = RemittanceTable(
data=Remittance.objects.filter(closed=True).filter(
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).reverse().all())
ctx["special_transactions_no_remittance"] = SpecialTransactionTable( context["special_transactions_no_remittance"] = SpecialTransactionTable(
data=SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)), data=SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)),
specialtransactionproxy__remittance=None).all(), specialtransactionproxy__remittance=None).filter(
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all(),
exclude=('remittance_remove', )) exclude=('remittance_remove', ))
ctx["special_transactions_with_remittance"] = SpecialTransactionTable( context["special_transactions_with_remittance"] = SpecialTransactionTable(
data=SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)), data=SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)),
specialtransactionproxy__remittance__closed=False).all(), specialtransactionproxy__remittance__closed=False).filter(
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all(),
exclude=('remittance_add', )) exclude=('remittance_add', ))
return ctx return context
class RemittanceUpdateView(LoginRequiredMixin, UpdateView): class RemittanceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
""" """
Update Remittance Update Remittance
""" """
@ -261,18 +252,20 @@ class RemittanceUpdateView(LoginRequiredMixin, UpdateView):
return reverse_lazy('treasury:remittance_list') return reverse_lazy('treasury:remittance_list')
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
ctx["table"] = RemittanceTable(data=Remittance.objects.all()) context["table"] = RemittanceTable(data=Remittance.objects.filter(
data = SpecialTransaction.objects.filter(specialtransactionproxy__remittance=self.object).all() PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all())
ctx["special_transactions"] = SpecialTransactionTable( data = SpecialTransaction.objects.filter(specialtransactionproxy__remittance=self.object).filter(
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all()
context["special_transactions"] = SpecialTransactionTable(
data=data, data=data,
exclude=('remittance_add', 'remittance_remove', ) if self.object.closed else ('remittance_add', )) exclude=('remittance_add', 'remittance_remove', ) if self.object.closed else ('remittance_add', ))
return ctx return context
class LinkTransactionToRemittanceView(LoginRequiredMixin, UpdateView): class LinkTransactionToRemittanceView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
""" """
Attach a special transaction to a remittance Attach a special transaction to a remittance
""" """
@ -284,9 +277,9 @@ class LinkTransactionToRemittanceView(LoginRequiredMixin, UpdateView):
return reverse_lazy('treasury:remittance_list') return reverse_lazy('treasury:remittance_list')
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
form = ctx["form"] form = context["form"]
form.fields["last_name"].initial = self.object.transaction.last_name form.fields["last_name"].initial = self.object.transaction.last_name
form.fields["first_name"].initial = self.object.transaction.first_name form.fields["first_name"].initial = self.object.transaction.first_name
form.fields["bank"].initial = self.object.transaction.bank form.fields["bank"].initial = self.object.transaction.bank
@ -294,7 +287,7 @@ class LinkTransactionToRemittanceView(LoginRequiredMixin, UpdateView):
form.fields["remittance"].queryset = form.fields["remittance"] \ form.fields["remittance"].queryset = form.fields["remittance"] \
.queryset.filter(remittance_type__note=self.object.transaction.source) .queryset.filter(remittance_type__note=self.object.transaction.source)
return ctx return context
class UnlinkTransactionToRemittanceView(LoginRequiredMixin, View): class UnlinkTransactionToRemittanceView(LoginRequiredMixin, View):

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

302
note_kfet/inputs.py Normal file
View File

@ -0,0 +1,302 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from json import dumps as json_dumps
from django.forms.widgets import DateTimeBaseInput, NumberInput, TextInput
class AmountInput(NumberInput):
"""
This input type lets the user type amounts in euros, but forms receive data in cents
"""
template_name = "note/amount_input.html"
def format_value(self, value):
return None if value is None or value == "" else "{:.02f}".format(int(value) / 100, )
def value_from_datadict(self, data, files, name):
val = super().value_from_datadict(data, files, name)
return str(int(100 * float(val))) if val else val
class Autocomplete(TextInput):
template_name = "member/autocomplete_model.html"
def __init__(self, model, attrs=None):
super().__init__(attrs)
self.model = model
self.model_pk = None
class Media:
"""JS/CSS resources needed to render the date-picker calendar."""
js = ('js/autocomplete_model.js', )
def format_value(self, value):
if value:
self.attrs["model_pk"] = int(value)
return str(self.model.objects.get(pk=int(value)))
return ""
"""
The remaining of this file comes from the project `django-bootstrap-datepicker-plus` available on Github:
https://github.com/monim67/django-bootstrap-datepicker-plus
This is distributed under Apache License 2.0.
This adds datetime pickers with bootstrap.
"""
"""Contains Base Date-Picker input class for widgets of this package."""
class DatePickerDictionary:
"""Keeps track of all date-picker input classes."""
_i = 0
items = dict()
@classmethod
def generate_id(cls):
"""Return a unique ID for each date-picker input class."""
cls._i += 1
return 'dp_%s' % cls._i
class BasePickerInput(DateTimeBaseInput):
"""Base Date-Picker input class for widgets of this package."""
template_name = 'bootstrap_datepicker_plus/date_picker.html'
picker_type = 'DATE'
format = '%Y-%m-%d'
config = {}
_default_config = {
'id': None,
'picker_type': None,
'linked_to': None,
'options': {} # final merged options
}
options = {} # options extended by user
options_param = {} # options passed as parameter
_default_options = {
'showClose': True,
'showClear': True,
'showTodayButton': True,
"locale": "fr",
}
# source: https://github.com/tutorcruncher/django-bootstrap3-datetimepicker
# file: /blob/31fbb09/bootstrap3_datetime/widgets.py#L33
format_map = (
('DDD', r'%j'),
('DD', r'%d'),
('MMMM', r'%B'),
('MMM', r'%b'),
('MM', r'%m'),
('YYYY', r'%Y'),
('YY', r'%y'),
('HH', r'%H'),
('hh', r'%I'),
('mm', r'%M'),
('ss', r'%S'),
('a', r'%p'),
('ZZ', r'%z'),
)
class Media:
"""JS/CSS resources needed to render the date-picker calendar."""
js = (
'https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.9.0/'
'moment-with-locales.min.js',
'https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/'
'4.17.47/js/bootstrap-datetimepicker.min.js',
'bootstrap_datepicker_plus/js/datepicker-widget.js'
)
css = {'all': (
'https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/'
'4.17.47/css/bootstrap-datetimepicker.css',
'bootstrap_datepicker_plus/css/datepicker-widget.css'
), }
@classmethod
def format_py2js(cls, datetime_format):
"""Convert python datetime format to moment datetime format."""
for js_format, py_format in cls.format_map:
datetime_format = datetime_format.replace(py_format, js_format)
return datetime_format
@classmethod
def format_js2py(cls, datetime_format):
"""Convert moment datetime format to python datetime format."""
for js_format, py_format in cls.format_map:
datetime_format = datetime_format.replace(js_format, py_format)
return datetime_format
def __init__(self, attrs=None, format=None, options=None):
"""Initialize the Date-picker widget."""
self.format_param = format
self.options_param = options if options else {}
self.config = self._default_config.copy()
self.config['id'] = DatePickerDictionary.generate_id()
self.config['picker_type'] = self.picker_type
self.config['options'] = self._calculate_options()
attrs = attrs if attrs else {}
if 'class' not in attrs:
attrs['class'] = 'form-control'
super().__init__(attrs, self._calculate_format())
def _calculate_options(self):
"""Calculate and Return the options."""
_options = self._default_options.copy()
_options.update(self.options)
if self.options_param:
_options.update(self.options_param)
return _options
def _calculate_format(self):
"""Calculate and Return the datetime format."""
_format = self.format_param if self.format_param else self.format
if self.config['options'].get('format'):
_format = self.format_js2py(self.config['options'].get('format'))
else:
self.config['options']['format'] = self.format_py2js(_format)
return _format
def get_context(self, name, value, attrs):
"""Return widget context dictionary."""
context = super().get_context(
name, value, attrs)
context['widget']['attrs']['dp_config'] = json_dumps(self.config)
return context
def start_of(self, event_id):
"""
Set Date-Picker as the start-date of a date-range.
Args:
- event_id (string): User-defined unique id for linking two fields
"""
DatePickerDictionary.items[str(event_id)] = self
return self
def end_of(self, event_id, import_options=True):
"""
Set Date-Picker as the end-date of a date-range.
Args:
- event_id (string): User-defined unique id for linking two fields
- import_options (bool): inherit options from start-date input,
default: TRUE
"""
event_id = str(event_id)
if event_id in DatePickerDictionary.items:
linked_picker = DatePickerDictionary.items[event_id]
self.config['linked_to'] = linked_picker.config['id']
if import_options:
backup_moment_format = self.config['options']['format']
self.config['options'].update(linked_picker.config['options'])
self.config['options'].update(self.options_param)
if self.format_param or 'format' in self.options_param:
self.config['options']['format'] = backup_moment_format
else:
self.format = linked_picker.format
# Setting useCurrent is necessary, see following issue
# https://github.com/Eonasdan/bootstrap-datetimepicker/issues/1075
self.config['options']['useCurrent'] = False
self._link_to(linked_picker)
else:
raise KeyError(
'start-date not specified for event_id "%s"' % event_id)
return self
def _link_to(self, linked_picker):
"""
Executed when two date-inputs are linked together.
This method for sub-classes to override to customize the linking.
"""
pass
class DatePickerInput(BasePickerInput):
"""
Widget to display a Date-Picker Calendar on a DateField property.
Args:
- attrs (dict): HTML attributes of rendered HTML input
- format (string): Python DateTime format eg. "%Y-%m-%d"
- options (dict): Options to customize the widget, see README
"""
picker_type = 'DATE'
format = '%Y-%m-%d'
format_key = 'DATE_INPUT_FORMATS'
class TimePickerInput(BasePickerInput):
"""
Widget to display a Time-Picker Calendar on a TimeField property.
Args:
- attrs (dict): HTML attributes of rendered HTML input
- format (string): Python DateTime format eg. "%Y-%m-%d"
- options (dict): Options to customize the widget, see README
"""
picker_type = 'TIME'
format = '%H:%M'
format_key = 'TIME_INPUT_FORMATS'
template_name = 'bootstrap_datepicker_plus/time_picker.html'
class DateTimePickerInput(BasePickerInput):
"""
Widget to display a DateTime-Picker Calendar on a DateTimeField property.
Args:
- attrs (dict): HTML attributes of rendered HTML input
- format (string): Python DateTime format eg. "%Y-%m-%d"
- options (dict): Options to customize the widget, see README
"""
picker_type = 'DATETIME'
format = '%Y-%m-%d %H:%M'
format_key = 'DATETIME_INPUT_FORMATS'
class MonthPickerInput(BasePickerInput):
"""
Widget to display a Month-Picker Calendar on a DateField property.
Args:
- attrs (dict): HTML attributes of rendered HTML input
- format (string): Python DateTime format eg. "%Y-%m-%d"
- options (dict): Options to customize the widget, see README
"""
picker_type = 'MONTH'
format = '01/%m/%Y'
format_key = 'DATE_INPUT_FORMATS'
class YearPickerInput(BasePickerInput):
"""
Widget to display a Year-Picker Calendar on a DateField property.
Args:
- attrs (dict): HTML attributes of rendered HTML input
- format (string): Python DateTime format eg. "%Y-%m-%d"
- options (dict): Options to customize the widget, see README
"""
picker_type = 'YEAR'
format = '01/01/%Y'
format_key = 'DATE_INPUT_FORMATS'
def _link_to(self, linked_picker):
"""Customize the options when linked with other date-time input"""
yformat = self.config['options']['format'].replace('-01-01', '-12-31')
self.config['options']['format'] = yformat

View File

@ -48,21 +48,20 @@ INSTALLED_APPS = [
'django.contrib.sites', 'django.contrib.sites',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django.forms',
# API # API
'rest_framework', 'rest_framework',
'rest_framework.authtoken', 'rest_framework.authtoken',
# Autocomplete
'dal',
'dal_select2',
# Note apps # Note apps
'api',
'activity', 'activity',
'logs',
'member', 'member',
'note', 'note',
'treasury',
'permission', 'permission',
'api', 'registration',
'logs', 'treasury',
] ]
LOGIN_REDIRECT_URL = '/note/transfer/' LOGIN_REDIRECT_URL = '/note/transfer/'
@ -100,6 +99,8 @@ TEMPLATES = [
}, },
] ]
FORM_RENDERER = 'django.forms.renderers.TemplatesSetting'
WSGI_APPLICATION = 'note_kfet.wsgi.application' WSGI_APPLICATION = 'note_kfet.wsgi.application'
# Password validation # Password validation

View File

@ -15,13 +15,15 @@ urlpatterns = [
# Include project routers # Include project routers
path('note/', include('note.urls')), path('note/', include('note.urls')),
path('accounts/', include('member.urls')),
path('registration/', include('registration.urls')),
path('activity/', include('activity.urls')),
path('treasury/', include('treasury.urls')), path('treasury/', include('treasury.urls')),
# Include Django Contrib and Core routers # Include Django Contrib and Core routers
path('i18n/', include('django.conf.urls.i18n')), path('i18n/', include('django.conf.urls.i18n')),
path('admin/doc/', include('django.contrib.admindocs.urls')), path('admin/doc/', include('django.contrib.admindocs.urls')),
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('accounts/', include('member.urls')),
path('accounts/login/', CustomLoginView.as_view()), path('accounts/login/', CustomLoginView.as_view()),
path('accounts/', include('django.contrib.auth.urls')), path('accounts/', include('django.contrib.auth.urls')),
path('api/', include('api.urls')), path('api/', include('api.urls')),
@ -36,14 +38,7 @@ if "cas_server" in settings.INSTALLED_APPS:
# Include CAS Server routers # Include CAS Server routers
path('cas/', include('cas_server.urls', namespace="cas_server")), path('cas/', include('cas_server.urls', namespace="cas_server")),
] ]
if "cas" in settings.INSTALLED_APPS:
from cas import views as cas_views
urlpatterns += [
# Include CAS Client routers
path('accounts/login/cas/', cas_views.login, name='cas_login'),
path('accounts/logout/cas/', cas_views.logout, name='cas_logout'),
]
if "debug_toolbar" in settings.INSTALLED_APPS: if "debug_toolbar" in settings.INSTALLED_APPS:
import debug_toolbar import debug_toolbar
urlpatterns = [ urlpatterns = [

View File

@ -3,7 +3,6 @@ chardet==3.0.4
defusedxml==0.6.0 defusedxml==0.6.0
Django~=2.2 Django~=2.2
django-allauth==0.39.1 django-allauth==0.39.1
django-autocomplete-light==3.5.1
django-crispy-forms==1.7.2 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

View File

@ -0,0 +1,121 @@
@font-face {
font-family: 'Glyphicons Halflings';
src: url('//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/fonts/glyphicons-halflings-regular.eot');
src: url('//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'),
url('//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/fonts/glyphicons-halflings-regular.woff2') format('woff2'),
url('//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/fonts/glyphicons-halflings-regular.woff') format('woff'),
url('//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/fonts/glyphicons-halflings-regular.ttf') format('truetype'),
url('//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg');
}
.glyphicon {
position: relative;
top: 1px;
display: inline-block;
font-family: 'Glyphicons Halflings';
font-style: normal;
font-weight: normal;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.glyphicon-time:before {
content: "\e023";
}
.glyphicon-chevron-left:before {
content: "\e079";
}
.glyphicon-chevron-right:before {
content: "\e080";
}
.glyphicon-chevron-up:before {
content: "\e113";
}
.glyphicon-chevron-down:before {
content: "\e114";
}
.glyphicon-calendar:before {
content: "\e109";
}
.glyphicon-screenshot:before {
content: "\e087";
}
.glyphicon-trash:before {
content: "\e020";
}
.glyphicon-remove:before {
content: "\e014";
}
.bootstrap-datetimepicker-widget .btn {
display: inline-block;
padding: 6px 12px;
margin-bottom: 0;
font-size: 14px;
font-weight: normal;
line-height: 1.42857143;
text-align: center;
white-space: nowrap;
vertical-align: middle;
-ms-touch-action: manipulation;
touch-action: manipulation;
cursor: pointer;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
background-image: none;
border: 1px solid transparent;
border-radius: 4px;
}
.bootstrap-datetimepicker-widget.dropdown-menu {
position: absolute;
left: 0;
z-index: 1000;
display: none;
float: left;
min-width: 160px;
padding: 5px 0;
margin: 2px 0 0;
font-size: 14px;
text-align: left;
list-style: none;
background-color: #fff;
-webkit-background-clip: padding-box;
background-clip: padding-box;
border: 1px solid #ccc;
border: 1px solid rgba(0, 0, 0, .15);
border-radius: 4px;
-webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, .175);
box-shadow: 0 6px 12px rgba(0, 0, 0, .175);
}
.bootstrap-datetimepicker-widget .list-unstyled {
padding-left: 0;
list-style: none;
}
.bootstrap-datetimepicker-widget .collapse {
display: none;
}
.bootstrap-datetimepicker-widget .collapse.in {
display: block;
}
/* fix for bootstrap4 */
.bootstrap-datetimepicker-widget .table-condensed > thead > tr > th,
.bootstrap-datetimepicker-widget .table-condensed > tbody > tr > td,
.bootstrap-datetimepicker-widget .table-condensed > tfoot > tr > td {
padding: 5px;
}

View File

@ -0,0 +1,55 @@
jQuery(function ($) {
var datepickerDict = {};
var isBootstrap4 = $.fn.collapse.Constructor.VERSION.split('.').shift() == "4";
function fixMonthEndDate(e, picker) {
e.date && picker.val().length && picker.val(e.date.endOf('month').format('YYYY-MM-DD'));
}
$("[dp_config]:not([disabled])").each(function (i, element) {
var $element = $(element), data = {};
try {
data = JSON.parse($element.attr('dp_config'));
}
catch (x) { }
if (data.id && data.options) {
data.$element = $element.datetimepicker(data.options);
data.datepickerdata = $element.data("DateTimePicker");
datepickerDict[data.id] = data;
data.$element.next('.input-group-addon').on('click', function(){
data.datepickerdata.show();
});
if(isBootstrap4){
data.$element.on("dp.show", function (e) {
$('.collapse.in').addClass('show');
});
}
}
});
$.each(datepickerDict, function (id, to_picker) {
if (to_picker.linked_to) {
var from_picker = datepickerDict[to_picker.linked_to];
from_picker.datepickerdata.maxDate(to_picker.datepickerdata.date() || false);
to_picker.datepickerdata.minDate(from_picker.datepickerdata.date() || false);
from_picker.$element.on("dp.change", function (e) {
to_picker.datepickerdata.minDate(e.date || false);
});
to_picker.$element.on("dp.change", function (e) {
if (to_picker.picker_type == 'MONTH') fixMonthEndDate(e, to_picker.$element);
from_picker.datepickerdata.maxDate(e.date || false);
});
if (to_picker.picker_type == 'MONTH') {
to_picker.$element.on("dp.hide", function (e) {
fixMonthEndDate(e, to_picker.$element);
});
fixMonthEndDate({ date: to_picker.datepickerdata.date() }, to_picker.$element);
}
}
});
if(isBootstrap4) {
$('body').on('show.bs.collapse','.bootstrap-datetimepicker-widget .collapse',function(e){
$(e.target).addClass('in');
});
$('body').on('hidden.bs.collapse','.bootstrap-datetimepicker-widget .collapse',function(e){
$(e.target).removeClass('in');
});
}
});

View File

@ -0,0 +1,37 @@
$(document).ready(function () {
$(".autocomplete").keyup(function(e) {
let target = $("#" + e.target.id);
let prefix = target.attr("id");
let api_url = target.attr("api_url");
let api_url_suffix = target.attr("api_url_suffix");
if (!api_url_suffix)
api_url_suffix = "";
let name_field = target.attr("name_field");
if (!name_field)
name_field = "name";
let input = target.val();
$.getJSON(api_url + "?format=json&search=^" + input + api_url_suffix, function(objects) {
let html = "";
objects.results.forEach(function (obj) {
html += li(prefix + "_" + obj.id, obj[name_field]);
});
$("#" + prefix + "_list").html(html);
objects.results.forEach(function (obj) {
$("#" + prefix + "_" + obj.id).click(function() {
target.val(obj[name_field]);
$("#" + prefix + "_pk").val(obj.id);
if (typeof autocompleted != 'undefined')
autocompleted(obj, prefix)
});
if (input === obj[name_field])
$("#" + prefix + "_pk").val(obj.id);
});
});
});
});

View File

@ -19,16 +19,53 @@ function pretty_money(value) {
* Add a message on the top of the page. * Add a message on the top of the page.
* @param msg The message to display * @param msg The message to display
* @param alert_type The type of the alert. Choices: info, success, warning, danger * @param alert_type The type of the alert. Choices: info, success, warning, danger
* @param timeout The delay (in millis) after that the message is auto-closed. If negative, then it is ignored.
*/ */
function addMsg(msg, alert_type) { function addMsg(msg, alert_type, timeout=-1) {
let msgDiv = $("#messages"); let msgDiv = $("#messages");
let html = msgDiv.html(); let html = msgDiv.html();
let id = Math.floor(10000 * Math.random() + 1);
html += "<div class=\"alert alert-" + alert_type + " alert-dismissible\">" + html += "<div class=\"alert alert-" + alert_type + " alert-dismissible\">" +
"<button class=\"close\" data-dismiss=\"alert\" href=\"#\"><span aria-hidden=\"true\">×</span></button>" "<button id=\"close-message-" + id + "\" class=\"close\" data-dismiss=\"alert\" href=\"#\"><span aria-hidden=\"true\">×</span></button>"
+ msg + "</div>\n"; + msg + "</div>\n";
msgDiv.html(html); msgDiv.html(html);
if (timeout > 0) {
setTimeout(function () {
$("#close-message-" + id).click();
}, timeout);
}
} }
/**
* add Muliple error message from err_obj
* @param errs_obj [{error_code:erro_message}]
* @param timeout The delay (in millis) after that the message is auto-closed. If negative, then it is ignored.
*/
function errMsg(errs_obj, timeout=-1) {
for (const err_msg of Object.values(errs_obj)) {
addMsg(err_msg,'danger', timeout);
}
}
var reloadWithTurbolinks = (function () {
var scrollPosition;
function reload () {
scrollPosition = [window.scrollX, window.scrollY];
Turbolinks.visit(window.location.toString(), { action: 'replace' })
}
document.addEventListener('turbolinks:load', function () {
if (scrollPosition) {
window.scrollTo.apply(window, scrollPosition);
scrollPosition = null
}
});
return reload;
})();
/** /**
* Reload the balance of the user on the right top corner * Reload the balance of the user on the right top corner
*/ */
@ -296,8 +333,9 @@ function de_validate(id, validated) {
"X-CSRFTOKEN": CSRF_TOKEN "X-CSRFTOKEN": CSRF_TOKEN
}, },
data: { data: {
"resourcetype": "RecurrentTransaction", resourcetype: "RecurrentTransaction",
valid: !validated valid: !validated,
invalidity_reason: invalidity_reason,
}, },
success: function () { success: function () {
// Refresh jQuery objects // Refresh jQuery objects

View File

@ -61,16 +61,24 @@ $(document).ready(function() {
// Ensure we begin in gift mode. Removing these lines may cause problems when reloading. // Ensure we begin in gift mode. Removing these lines may cause problems when reloading.
$("#type_gift").prop('checked', 'true'); let type_gift = $("#type_gift"); // Default mode
type_gift.removeAttr('checked');
$("#type_transfer").removeAttr('checked'); $("#type_transfer").removeAttr('checked');
$("#type_credit").removeAttr('checked'); $("#type_credit").removeAttr('checked');
$("#type_debit").removeAttr('checked'); $("#type_debit").removeAttr('checked');
$("label[for='type_gift']").attr('class', 'btn btn-sm btn-outline-primary');
$("label[for='type_transfer']").attr('class', 'btn btn-sm btn-outline-primary'); $("label[for='type_transfer']").attr('class', 'btn btn-sm btn-outline-primary');
$("label[for='type_credit']").attr('class', 'btn btn-sm btn-outline-primary'); $("label[for='type_credit']").attr('class', 'btn btn-sm btn-outline-primary');
$("label[for='type_debit']").attr('class', 'btn btn-sm btn-outline-primary'); $("label[for='type_debit']").attr('class', 'btn btn-sm btn-outline-primary');
if (location.hash)
$("#type_" + location.hash.substr(1)).click();
else
type_gift.click();
location.hash = "";
}); });
$("#transfer").click(function() { $("#btn_transfer").click(function() {
if ($("#type_gift").is(':checked')) { if ($("#type_gift").is(':checked')) {
dests_notes_display.forEach(function (dest) { dests_notes_display.forEach(function (dest) {
$.post("/api/note/transaction/transaction/", $.post("/api/note/transaction/transaction/",

View File

@ -1,262 +0,0 @@
const sass = require('node-sass');
module.exports = function (grunt) {
// Full list of files that must be included by RequireJS
includes = [
'jquery.select2',
'almond',
'jquery-mousewheel' // shimmed for non-full builds
];
fullIncludes = [
'jquery',
'select2/compat/containerCss',
'select2/compat/dropdownCss',
'select2/compat/initSelection',
'select2/compat/inputData',
'select2/compat/matcher',
'select2/compat/query',
'select2/dropdown/attachContainer',
'select2/dropdown/stopPropagation',
'select2/selection/stopPropagation'
].concat(includes);
var i18nModules = [];
var i18nPaths = {};
var i18nFiles = grunt.file.expand({
cwd: 'src/js'
}, 'select2/i18n/*.js');
var testFiles = grunt.file.expand('tests/**/*.html');
var testUrls = testFiles.map(function (filePath) {
return 'http://localhost:9999/' + filePath;
});
var testBuildNumber = "unknown";
if (process.env.TRAVIS_JOB_ID) {
testBuildNumber = "travis-" + process.env.TRAVIS_JOB_ID;
} else {
var currentTime = new Date();
testBuildNumber = "manual-" + currentTime.getTime();
}
for (var i = 0; i < i18nFiles.length; i++) {
var file = i18nFiles[i];
var name = file.split('.')[0];
i18nModules.push({
name: name
});
i18nPaths[name] = '../../' + name;
}
var minifiedBanner = '/*! Select2 <%= package.version %> | https://github.com/select2/select2/blob/master/LICENSE.md */';
grunt.initConfig({
package: grunt.file.readJSON('package.json'),
concat: {
'dist': {
options: {
banner: grunt.file.read('src/js/wrapper.start.js'),
},
src: [
'dist/js/select2.js',
'src/js/wrapper.end.js'
],
dest: 'dist/js/select2.js'
},
'dist.full': {
options: {
banner: grunt.file.read('src/js/wrapper.start.js'),
},
src: [
'dist/js/select2.full.js',
'src/js/wrapper.end.js'
],
dest: 'dist/js/select2.full.js'
}
},
connect: {
tests: {
options: {
base: '.',
hostname: '127.0.0.1',
port: 9999
}
}
},
uglify: {
'dist': {
src: 'dist/js/select2.js',
dest: 'dist/js/select2.min.js',
options: {
banner: minifiedBanner
}
},
'dist.full': {
src: 'dist/js/select2.full.js',
dest: 'dist/js/select2.full.min.js',
options: {
banner: minifiedBanner
}
}
},
qunit: {
all: {
options: {
urls: testUrls
}
}
},
jshint: {
options: {
jshintrc: true,
reporterOutput: ''
},
code: {
src: ['src/js/**/*.js']
},
tests: {
src: ['tests/**/*.js']
}
},
sass: {
dist: {
options: {
implementation: sass,
outputStyle: 'compressed'
},
files: {
'dist/css/select2.min.css': [
'src/scss/core.scss',
'src/scss/theme/default/layout.css'
]
}
},
dev: {
options: {
implementation: sass,
outputStyle: 'nested'
},
files: {
'dist/css/select2.css': [
'src/scss/core.scss',
'src/scss/theme/default/layout.css'
]
}
}
},
requirejs: {
'dist': {
options: {
baseUrl: 'src/js',
optimize: 'none',
name: 'select2/core',
out: 'dist/js/select2.js',
include: includes,
namespace: 'S2',
paths: {
'almond': require.resolve('almond').slice(0, -3),
'jquery': 'jquery.shim',
'jquery-mousewheel': 'jquery.mousewheel.shim'
},
wrap: {
startFile: 'src/js/banner.start.js',
endFile: 'src/js/banner.end.js'
}
}
},
'dist.full': {
options: {
baseUrl: 'src/js',
optimize: 'none',
name: 'select2/core',
out: 'dist/js/select2.full.js',
include: fullIncludes,
namespace: 'S2',
paths: {
'almond': require.resolve('almond').slice(0, -3),
'jquery': 'jquery.shim',
'jquery-mousewheel': require.resolve('jquery-mousewheel').slice(0, -3)
},
wrap: {
startFile: 'src/js/banner.start.js',
endFile: 'src/js/banner.end.js'
}
}
},
'i18n': {
options: {
baseUrl: 'src/js/select2/i18n',
dir: 'dist/js/i18n',
paths: i18nPaths,
modules: i18nModules,
namespace: 'S2',
wrap: {
start: minifiedBanner + grunt.file.read('src/js/banner.start.js'),
end: grunt.file.read('src/js/banner.end.js')
}
}
}
},
watch: {
js: {
files: [
'src/js/select2/**/*.js',
'tests/**/*.js'
],
tasks: [
'compile',
'test',
'minify'
]
},
css: {
files: [
'src/scss/**/*.scss'
],
tasks: [
'compile',
'minify'
]
}
}
});
grunt.loadNpmTasks('grunt-contrib-concat');
grunt.loadNpmTasks('grunt-contrib-connect');
grunt.loadNpmTasks('grunt-contrib-jshint');
grunt.loadNpmTasks('grunt-contrib-qunit');
grunt.loadNpmTasks('grunt-contrib-requirejs');
grunt.loadNpmTasks('grunt-contrib-uglify');
grunt.loadNpmTasks('grunt-contrib-watch');
grunt.loadNpmTasks('grunt-sass');
grunt.registerTask('default', ['compile', 'test', 'minify']);
grunt.registerTask('compile', [
'requirejs:dist', 'requirejs:dist.full', 'requirejs:i18n',
'concat:dist', 'concat:dist.full',
'sass:dev'
]);
grunt.registerTask('minify', ['uglify', 'sass:dist']);
grunt.registerTask('test', ['connect:tests', 'qunit', 'jshint']);
};

View File

@ -1,13 +0,0 @@
{
"name": "select2",
"description": "Select2 is a jQuery based replacement for select boxes. It supports searching, remote data sets, and infinite scrolling of results.",
"main": [
"dist/js/select2.js",
"src/scss/core.scss"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "git@github.com:select2/select2.git"
}
}

View File

@ -1,19 +0,0 @@
{
"name": "select2",
"repo": "select/select2",
"description": "Select2 is a jQuery based replacement for select boxes. It supports searching, remote data sets, and infinite scrolling of results.",
"version": "4.0.7",
"demo": "https://select2.org/",
"keywords": [
"jquery"
],
"main": "dist/js/select2.js",
"styles": [
"dist/css/select2.css"
],
"scripts": [
"dist/js/select2.js",
"dist/js/i18n/*.js"
],
"license": "MIT"
}

View File

@ -1,22 +0,0 @@
{
"name": "select2/select2",
"description": "Select2 is a jQuery based replacement for select boxes.",
"type": "component",
"homepage": "https://select2.org/",
"license": "MIT",
"extra": {
"component": {
"scripts": [
"dist/js/select2.js"
],
"styles": [
"dist/css/select2.css"
],
"files": [
"dist/js/select2.js",
"dist/js/i18n/*.js",
"dist/css/select2.css"
]
}
}
}

View File

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>select2</title>
</head>
<body>
<script>
window.location = 'https://select2.org/upgrading/new-in-40';
</script>
</body>
</html>

View File

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>select2</title>
</head>
<body>
<script>
window.location = 'https://select2.org/getting-help';
</script>
</body>
</html>

View File

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>select2</title>
</head>
<body>
<script>
window.location = 'https://select2.org/getting-started/basic-usage';
</script>
</body>
</html>

View File

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>select2</title>
</head>
<body>
<script>
window.location = 'https://select2.org';
</script>
</body>
</html>

View File

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>select2</title>
</head>
<body>
<script>
window.location = 'https://select2.org/configuration';
</script>
</body>
</html>

View File

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>select2</title>
</head>
<body>
<script>
window.location = 'https://select2.org/configuration';
</script>
</body>
</html>

View File

@ -1,65 +0,0 @@
{
"name": "select2",
"description": "Select2 is a jQuery based replacement for select boxes. It supports searching, remote data sets, and infinite scrolling of results.",
"homepage": "https://select2.org",
"author": {
"name": "Kevin Brown",
"url": "https://github.com/kevin-brown"
},
"contributors": [
{
"name": "Igor Vaynberg",
"url": "https://github.com/ivaynberg"
},
{
"name": "Alex Weissman",
"url": "https://github.com/alexweissman"
}
],
"repository": {
"type": "git",
"url": "git://github.com/select2/select2.git"
},
"bugs": {
"url": "https://github.com/select2/select2/issues"
},
"keywords": [
"select",
"autocomplete",
"typeahead",
"dropdown",
"multiselect",
"tag",
"tagging"
],
"license": "MIT",
"main": "dist/js/select2.js",
"style": "dist/css/select2.css",
"files": [
"src",
"dist"
],
"version": "4.0.7",
"jspm": {
"main": "js/select2",
"directories": {
"lib": "dist"
}
},
"devDependencies": {
"almond": "~0.3.1",
"grunt": "^0.4.5",
"grunt-cli": "^1.3.2",
"grunt-contrib-concat": "^1.0.1",
"grunt-contrib-connect": "^2.0.0",
"grunt-contrib-jshint": "^1.1.0",
"grunt-contrib-qunit": "^1.3.0",
"grunt-contrib-requirejs": "^1.0.0",
"grunt-contrib-uglify": "~4.0.1",
"grunt-contrib-watch": "~1.1.0",
"grunt-sass": "^2.1.0",
"jquery-mousewheel": "~3.1.13",
"node-sass": "^4.12.0"
},
"dependencies": {}
}

View File

@ -1,6 +0,0 @@
// Return the AMD loader configuration so it can be used outside of this file
return {
define: S2.define,
require: S2.require
};
}());

View File

@ -1,6 +0,0 @@
(function () {
// Restore the Select2 AMD loader so it can be used
// Needed mostly in the language files, where the loader is not inserted
if (jQuery && jQuery.fn && jQuery.fn.select2 && jQuery.fn.select2.amd) {
var S2 = jQuery.fn.select2.amd;
}

View File

@ -1,6 +0,0 @@
define([
'jquery'
], function ($) {
// Used to shim jQuery.mousewheel for non-full builds.
return $;
});

View File

@ -1,58 +0,0 @@
define([
'jquery',
'jquery-mousewheel',
'./select2/core',
'./select2/defaults',
'./select2/utils'
], function ($, _, Select2, Defaults, Utils) {
if ($.fn.select2 == null) {
// All methods that should return the element
var thisMethods = ['open', 'close', 'destroy'];
$.fn.select2 = function (options) {
options = options || {};
if (typeof options === 'object') {
this.each(function () {
var instanceOptions = $.extend(true, {}, options);
var instance = new Select2($(this), instanceOptions);
});
return this;
} else if (typeof options === 'string') {
var ret;
var args = Array.prototype.slice.call(arguments, 1);
this.each(function () {
var instance = Utils.GetData(this, 'select2');
if (instance == null && window.console && console.error) {
console.error(
'The select2(\'' + options + '\') method was called on an ' +
'element that is not using Select2.'
);
}
ret = instance[options].apply(instance, args);
});
// Check if we should be returning `this`
if ($.inArray(options, thisMethods) > -1) {
return this;
}
return ret;
} else {
throw new Error('Invalid arguments for Select2: ' + options);
}
};
}
if ($.fn.select2.defaults == null) {
$.fn.select2.defaults = Defaults;
}
return Select2;
});

View File

@ -1,14 +0,0 @@
/* global jQuery:false, $:false */
define(function () {
var _$ = jQuery || $;
if (_$ == null && console && console.error) {
console.error(
'Select2: An instance of jQuery or a jQuery-compatible library was not ' +
'found. Make sure that you are including jQuery before Select2 on your ' +
'web page.'
);
}
return _$;
});

View File

@ -1,56 +0,0 @@
define([
'jquery',
'./utils'
], function ($, CompatUtils) {
// No-op CSS adapter that discards all classes by default
function _containerAdapter (clazz) {
return null;
}
function ContainerCSS () { }
ContainerCSS.prototype.render = function (decorated) {
var $container = decorated.call(this);
var containerCssClass = this.options.get('containerCssClass') || '';
if ($.isFunction(containerCssClass)) {
containerCssClass = containerCssClass(this.$element);
}
var containerCssAdapter = this.options.get('adaptContainerCssClass');
containerCssAdapter = containerCssAdapter || _containerAdapter;
if (containerCssClass.indexOf(':all:') !== -1) {
containerCssClass = containerCssClass.replace(':all:', '');
var _cssAdapter = containerCssAdapter;
containerCssAdapter = function (clazz) {
var adapted = _cssAdapter(clazz);
if (adapted != null) {
// Append the old one along with the adapted one
return adapted + ' ' + clazz;
}
return clazz;
};
}
var containerCss = this.options.get('containerCss') || {};
if ($.isFunction(containerCss)) {
containerCss = containerCss(this.$element);
}
CompatUtils.syncCssClasses($container, this.$element, containerCssAdapter);
$container.css(containerCss);
$container.addClass(containerCssClass);
return $container;
};
return ContainerCSS;
});

View File

@ -1,56 +0,0 @@
define([
'jquery',
'./utils'
], function ($, CompatUtils) {
// No-op CSS adapter that discards all classes by default
function _dropdownAdapter (clazz) {
return null;
}
function DropdownCSS () { }
DropdownCSS.prototype.render = function (decorated) {
var $dropdown = decorated.call(this);
var dropdownCssClass = this.options.get('dropdownCssClass') || '';
if ($.isFunction(dropdownCssClass)) {
dropdownCssClass = dropdownCssClass(this.$element);
}
var dropdownCssAdapter = this.options.get('adaptDropdownCssClass');
dropdownCssAdapter = dropdownCssAdapter || _dropdownAdapter;
if (dropdownCssClass.indexOf(':all:') !== -1) {
dropdownCssClass = dropdownCssClass.replace(':all:', '');
var _cssAdapter = dropdownCssAdapter;
dropdownCssAdapter = function (clazz) {
var adapted = _cssAdapter(clazz);
if (adapted != null) {
// Append the old one along with the adapted one
return adapted + ' ' + clazz;
}
return clazz;
};
}
var dropdownCss = this.options.get('dropdownCss') || {};
if ($.isFunction(dropdownCss)) {
dropdownCss = dropdownCss(this.$element);
}
CompatUtils.syncCssClasses($dropdown, this.$element, dropdownCssAdapter);
$dropdown.css(dropdownCss);
$dropdown.addClass(dropdownCssClass);
return $dropdown;
};
return DropdownCSS;
});

View File

@ -1,42 +0,0 @@
define([
'jquery'
], function ($) {
function InitSelection (decorated, $element, options) {
if (options.get('debug') && window.console && console.warn) {
console.warn(
'Select2: The `initSelection` option has been deprecated in favor' +
' of a custom data adapter that overrides the `current` method. ' +
'This method is now called multiple times instead of a single ' +
'time when the instance is initialized. Support will be removed ' +
'for the `initSelection` option in future versions of Select2'
);
}
this.initSelection = options.get('initSelection');
this._isInitialized = false;
decorated.call(this, $element, options);
}
InitSelection.prototype.current = function (decorated, callback) {
var self = this;
if (this._isInitialized) {
decorated.call(this, callback);
return;
}
this.initSelection.call(null, this.$element, function (data) {
self._isInitialized = true;
if (!$.isArray(data)) {
data = [data];
}
callback(data);
});
};
return InitSelection;
});

View File

@ -1,128 +0,0 @@
define([
'jquery',
'../utils'
], function ($, Utils) {
function InputData (decorated, $element, options) {
this._currentData = [];
this._valueSeparator = options.get('valueSeparator') || ',';
if ($element.prop('type') === 'hidden') {
if (options.get('debug') && console && console.warn) {
console.warn(
'Select2: Using a hidden input with Select2 is no longer ' +
'supported and may stop working in the future. It is recommended ' +
'to use a `<select>` element instead.'
);
}
}
decorated.call(this, $element, options);
}
InputData.prototype.current = function (_, callback) {
function getSelected (data, selectedIds) {
var selected = [];
if (data.selected || $.inArray(data.id, selectedIds) !== -1) {
data.selected = true;
selected.push(data);
} else {
data.selected = false;
}
if (data.children) {
selected.push.apply(selected, getSelected(data.children, selectedIds));
}
return selected;
}
var selected = [];
for (var d = 0; d < this._currentData.length; d++) {
var data = this._currentData[d];
selected.push.apply(
selected,
getSelected(
data,
this.$element.val().split(
this._valueSeparator
)
)
);
}
callback(selected);
};
InputData.prototype.select = function (_, data) {
if (!this.options.get('multiple')) {
this.current(function (allData) {
$.map(allData, function (data) {
data.selected = false;
});
});
this.$element.val(data.id);
this.$element.trigger('change');
} else {
var value = this.$element.val();
value += this._valueSeparator + data.id;
this.$element.val(value);
this.$element.trigger('change');
}
};
InputData.prototype.unselect = function (_, data) {
var self = this;
data.selected = false;
this.current(function (allData) {
var values = [];
for (var d = 0; d < allData.length; d++) {
var item = allData[d];
if (data.id == item.id) {
continue;
}
values.push(item.id);
}
self.$element.val(values.join(self._valueSeparator));
self.$element.trigger('change');
});
};
InputData.prototype.query = function (_, params, callback) {
var results = [];
for (var d = 0; d < this._currentData.length; d++) {
var data = this._currentData[d];
var matches = this.matches(params, data);
if (matches !== null) {
results.push(matches);
}
}
callback({
results: results
});
};
InputData.prototype.addOptions = function (_, $options) {
var options = $.map($options, function ($option) {
return Utils.GetData($option[0], 'data');
});
this._currentData.push.apply(this._currentData, options);
};
return InputData;
});

View File

@ -1,42 +0,0 @@
define([
'jquery'
], function ($) {
function oldMatcher (matcher) {
function wrappedMatcher (params, data) {
var match = $.extend(true, {}, data);
if (params.term == null || $.trim(params.term) === '') {
return match;
}
if (data.children) {
for (var c = data.children.length - 1; c >= 0; c--) {
var child = data.children[c];
// Check if the child object matches
// The old matcher returned a boolean true or false
var doesMatch = matcher(params.term, child.text, child);
// If the child didn't match, pop it off
if (!doesMatch) {
match.children.splice(c, 1);
}
}
if (match.children.length > 0) {
return match;
}
}
if (matcher(params.term, data.text, data)) {
return match;
}
return null;
}
return wrappedMatcher;
}
return oldMatcher;
});

View File

@ -1,26 +0,0 @@
define([
], function () {
function Query (decorated, $element, options) {
if (options.get('debug') && window.console && console.warn) {
console.warn(
'Select2: The `query` option has been deprecated in favor of a ' +
'custom data adapter that overrides the `query` method. Support ' +
'will be removed for the `query` option in future versions of ' +
'Select2.'
);
}
decorated.call(this, $element, options);
}
Query.prototype.query = function (_, params, callback) {
params.callback = callback;
var query = this.options.get('query');
query.call(null, params);
};
return Query;
});

View File

@ -1,43 +0,0 @@
define([
'jquery'
], function ($) {
function syncCssClasses ($dest, $src, adapter) {
var classes, replacements = [], adapted;
classes = $.trim($dest.attr('class'));
if (classes) {
classes = '' + classes; // for IE which returns object
$(classes.split(/\s+/)).each(function () {
// Save all Select2 classes
if (this.indexOf('select2-') === 0) {
replacements.push(this);
}
});
}
classes = $.trim($src.attr('class'));
if (classes) {
classes = '' + classes; // for IE which returns object
$(classes.split(/\s+/)).each(function () {
// Only adapt non-Select2 classes
if (this.indexOf('select2-') !== 0) {
adapted = adapter(this);
if (adapted != null) {
replacements.push(adapted);
}
}
});
}
$dest.attr('class', replacements.join(' '));
}
return {
syncCssClasses: syncCssClasses
};
});

View File

@ -1,618 +0,0 @@
define([
'jquery',
'./options',
'./utils',
'./keys'
], function ($, Options, Utils, KEYS) {
var Select2 = function ($element, options) {
if (Utils.GetData($element[0], 'select2') != null) {
Utils.GetData($element[0], 'select2').destroy();
}
this.$element = $element;
this.id = this._generateId($element);
options = options || {};
this.options = new Options(options, $element);
Select2.__super__.constructor.call(this);
// Set up the tabindex
var tabindex = $element.attr('tabindex') || 0;
Utils.StoreData($element[0], 'old-tabindex', tabindex);
$element.attr('tabindex', '-1');
// Set up containers and adapters
var DataAdapter = this.options.get('dataAdapter');
this.dataAdapter = new DataAdapter($element, this.options);
var $container = this.render();
this._placeContainer($container);
var SelectionAdapter = this.options.get('selectionAdapter');
this.selection = new SelectionAdapter($element, this.options);
this.$selection = this.selection.render();
this.selection.position(this.$selection, $container);
var DropdownAdapter = this.options.get('dropdownAdapter');
this.dropdown = new DropdownAdapter($element, this.options);
this.$dropdown = this.dropdown.render();
this.dropdown.position(this.$dropdown, $container);
var ResultsAdapter = this.options.get('resultsAdapter');
this.results = new ResultsAdapter($element, this.options, this.dataAdapter);
this.$results = this.results.render();
this.results.position(this.$results, this.$dropdown);
// Bind events
var self = this;
// Bind the container to all of the adapters
this._bindAdapters();
// Register any DOM event handlers
this._registerDomEvents();
// Register any internal event handlers
this._registerDataEvents();
this._registerSelectionEvents();
this._registerDropdownEvents();
this._registerResultsEvents();
this._registerEvents();
// Set the initial state
this.dataAdapter.current(function (initialData) {
self.trigger('selection:update', {
data: initialData
});
});
// Hide the original select
$element.addClass('select2-hidden-accessible');
$element.attr('aria-hidden', 'true');
// Synchronize any monitored attributes
this._syncAttributes();
Utils.StoreData($element[0], 'select2', this);
// Ensure backwards compatibility with $element.data('select2').
$element.data('select2', this);
};
Utils.Extend(Select2, Utils.Observable);
Select2.prototype._generateId = function ($element) {
var id = '';
if ($element.attr('id') != null) {
id = $element.attr('id');
} else if ($element.attr('name') != null) {
id = $element.attr('name') + '-' + Utils.generateChars(2);
} else {
id = Utils.generateChars(4);
}
id = id.replace(/(:|\.|\[|\]|,)/g, '');
id = 'select2-' + id;
return id;
};
Select2.prototype._placeContainer = function ($container) {
$container.insertAfter(this.$element);
var width = this._resolveWidth(this.$element, this.options.get('width'));
if (width != null) {
$container.css('width', width);
}
};
Select2.prototype._resolveWidth = function ($element, method) {
var WIDTH = /^width:(([-+]?([0-9]*\.)?[0-9]+)(px|em|ex|%|in|cm|mm|pt|pc))/i;
if (method == 'resolve') {
var styleWidth = this._resolveWidth($element, 'style');
if (styleWidth != null) {
return styleWidth;
}
return this._resolveWidth($element, 'element');
}
if (method == 'element') {
var elementWidth = $element.outerWidth(false);
if (elementWidth <= 0) {
return 'auto';
}
return elementWidth + 'px';
}
if (method == 'style') {
var style = $element.attr('style');
if (typeof(style) !== 'string') {
return null;
}
var attrs = style.split(';');
for (var i = 0, l = attrs.length; i < l; i = i + 1) {
var attr = attrs[i].replace(/\s/g, '');
var matches = attr.match(WIDTH);
if (matches !== null && matches.length >= 1) {
return matches[1];
}
}
return null;
}
return method;
};
Select2.prototype._bindAdapters = function () {
this.dataAdapter.bind(this, this.$container);
this.selection.bind(this, this.$container);
this.dropdown.bind(this, this.$container);
this.results.bind(this, this.$container);
};
Select2.prototype._registerDomEvents = function () {
var self = this;
this.$element.on('change.select2', function () {
self.dataAdapter.current(function (data) {
self.trigger('selection:update', {
data: data
});
});
});
this.$element.on('focus.select2', function (evt) {
self.trigger('focus', evt);
});
this._syncA = Utils.bind(this._syncAttributes, this);
this._syncS = Utils.bind(this._syncSubtree, this);
if (this.$element[0].attachEvent) {
this.$element[0].attachEvent('onpropertychange', this._syncA);
}
var observer = window.MutationObserver ||
window.WebKitMutationObserver ||
window.MozMutationObserver
;
if (observer != null) {
this._observer = new observer(function (mutations) {
$.each(mutations, self._syncA);
$.each(mutations, self._syncS);
});
this._observer.observe(this.$element[0], {
attributes: true,
childList: true,
subtree: false
});
} else if (this.$element[0].addEventListener) {
this.$element[0].addEventListener(
'DOMAttrModified',
self._syncA,
false
);
this.$element[0].addEventListener(
'DOMNodeInserted',
self._syncS,
false
);
this.$element[0].addEventListener(
'DOMNodeRemoved',
self._syncS,
false
);
}
};
Select2.prototype._registerDataEvents = function () {
var self = this;
this.dataAdapter.on('*', function (name, params) {
self.trigger(name, params);
});
};
Select2.prototype._registerSelectionEvents = function () {
var self = this;
var nonRelayEvents = ['toggle', 'focus'];
this.selection.on('toggle', function () {
self.toggleDropdown();
});
this.selection.on('focus', function (params) {
self.focus(params);
});
this.selection.on('*', function (name, params) {
if ($.inArray(name, nonRelayEvents) !== -1) {
return;
}
self.trigger(name, params);
});
};
Select2.prototype._registerDropdownEvents = function () {
var self = this;
this.dropdown.on('*', function (name, params) {
self.trigger(name, params);
});
};
Select2.prototype._registerResultsEvents = function () {
var self = this;
this.results.on('*', function (name, params) {
self.trigger(name, params);
});
};
Select2.prototype._registerEvents = function () {
var self = this;
this.on('open', function () {
self.$container.addClass('select2-container--open');
});
this.on('close', function () {
self.$container.removeClass('select2-container--open');
});
this.on('enable', function () {
self.$container.removeClass('select2-container--disabled');
});
this.on('disable', function () {
self.$container.addClass('select2-container--disabled');
});
this.on('blur', function () {
self.$container.removeClass('select2-container--focus');
});
this.on('query', function (params) {
if (!self.isOpen()) {
self.trigger('open', {});
}
this.dataAdapter.query(params, function (data) {
self.trigger('results:all', {
data: data,
query: params
});
});
});
this.on('query:append', function (params) {
this.dataAdapter.query(params, function (data) {
self.trigger('results:append', {
data: data,
query: params
});
});
});
this.on('keypress', function (evt) {
var key = evt.which;
if (self.isOpen()) {
if (key === KEYS.ESC || key === KEYS.TAB ||
(key === KEYS.UP && evt.altKey)) {
self.close();
evt.preventDefault();
} else if (key === KEYS.ENTER) {
self.trigger('results:select', {});
evt.preventDefault();
} else if ((key === KEYS.SPACE && evt.ctrlKey)) {
self.trigger('results:toggle', {});
evt.preventDefault();
} else if (key === KEYS.UP) {
self.trigger('results:previous', {});
evt.preventDefault();
} else if (key === KEYS.DOWN) {
self.trigger('results:next', {});
evt.preventDefault();
}
} else {
if (key === KEYS.ENTER || key === KEYS.SPACE ||
(key === KEYS.DOWN && evt.altKey)) {
self.open();
evt.preventDefault();
}
}
});
};
Select2.prototype._syncAttributes = function () {
this.options.set('disabled', this.$element.prop('disabled'));
if (this.options.get('disabled')) {
if (this.isOpen()) {
this.close();
}
this.trigger('disable', {});
} else {
this.trigger('enable', {});
}
};
Select2.prototype._syncSubtree = function (evt, mutations) {
var changed = false;
var self = this;
// Ignore any mutation events raised for elements that aren't options or
// optgroups. This handles the case when the select element is destroyed
if (
evt && evt.target && (
evt.target.nodeName !== 'OPTION' && evt.target.nodeName !== 'OPTGROUP'
)
) {
return;
}
if (!mutations) {
// If mutation events aren't supported, then we can only assume that the
// change affected the selections
changed = true;
} else if (mutations.addedNodes && mutations.addedNodes.length > 0) {
for (var n = 0; n < mutations.addedNodes.length; n++) {
var node = mutations.addedNodes[n];
if (node.selected) {
changed = true;
}
}
} else if (mutations.removedNodes && mutations.removedNodes.length > 0) {
changed = true;
}
// Only re-pull the data if we think there is a change
if (changed) {
this.dataAdapter.current(function (currentData) {
self.trigger('selection:update', {
data: currentData
});
});
}
};
/**
* Override the trigger method to automatically trigger pre-events when
* there are events that can be prevented.
*/
Select2.prototype.trigger = function (name, args) {
var actualTrigger = Select2.__super__.trigger;
var preTriggerMap = {
'open': 'opening',
'close': 'closing',
'select': 'selecting',
'unselect': 'unselecting',
'clear': 'clearing'
};
if (args === undefined) {
args = {};
}
if (name in preTriggerMap) {
var preTriggerName = preTriggerMap[name];
var preTriggerArgs = {
prevented: false,
name: name,
args: args
};
actualTrigger.call(this, preTriggerName, preTriggerArgs);
if (preTriggerArgs.prevented) {
args.prevented = true;
return;
}
}
actualTrigger.call(this, name, args);
};
Select2.prototype.toggleDropdown = function () {
if (this.options.get('disabled')) {
return;
}
if (this.isOpen()) {
this.close();
} else {
this.open();
}
};
Select2.prototype.open = function () {
if (this.isOpen()) {
return;
}
this.trigger('query', {});
};
Select2.prototype.close = function () {
if (!this.isOpen()) {
return;
}
this.trigger('close', {});
};
Select2.prototype.isOpen = function () {
return this.$container.hasClass('select2-container--open');
};
Select2.prototype.hasFocus = function () {
return this.$container.hasClass('select2-container--focus');
};
Select2.prototype.focus = function (data) {
// No need to re-trigger focus events if we are already focused
if (this.hasFocus()) {
return;
}
this.$container.addClass('select2-container--focus');
this.trigger('focus', {});
};
Select2.prototype.enable = function (args) {
if (this.options.get('debug') && window.console && console.warn) {
console.warn(
'Select2: The `select2("enable")` method has been deprecated and will' +
' be removed in later Select2 versions. Use $element.prop("disabled")' +
' instead.'
);
}
if (args == null || args.length === 0) {
args = [true];
}
var disabled = !args[0];
this.$element.prop('disabled', disabled);
};
Select2.prototype.data = function () {
if (this.options.get('debug') &&
arguments.length > 0 && window.console && console.warn) {
console.warn(
'Select2: Data can no longer be set using `select2("data")`. You ' +
'should consider setting the value instead using `$element.val()`.'
);
}
var data = [];
this.dataAdapter.current(function (currentData) {
data = currentData;
});
return data;
};
Select2.prototype.val = function (args) {
if (this.options.get('debug') && window.console && console.warn) {
console.warn(
'Select2: The `select2("val")` method has been deprecated and will be' +
' removed in later Select2 versions. Use $element.val() instead.'
);
}
if (args == null || args.length === 0) {
return this.$element.val();
}
var newVal = args[0];
if ($.isArray(newVal)) {
newVal = $.map(newVal, function (obj) {
return obj.toString();
});
}
this.$element.val(newVal).trigger('change');
};
Select2.prototype.destroy = function () {
this.$container.remove();
if (this.$element[0].detachEvent) {
this.$element[0].detachEvent('onpropertychange', this._syncA);
}
if (this._observer != null) {
this._observer.disconnect();
this._observer = null;
} else if (this.$element[0].removeEventListener) {
this.$element[0]
.removeEventListener('DOMAttrModified', this._syncA, false);
this.$element[0]
.removeEventListener('DOMNodeInserted', this._syncS, false);
this.$element[0]
.removeEventListener('DOMNodeRemoved', this._syncS, false);
}
this._syncA = null;
this._syncS = null;
this.$element.off('.select2');
this.$element.attr('tabindex',
Utils.GetData(this.$element[0], 'old-tabindex'));
this.$element.removeClass('select2-hidden-accessible');
this.$element.attr('aria-hidden', 'false');
Utils.RemoveData(this.$element[0]);
this.$element.removeData('select2');
this.dataAdapter.destroy();
this.selection.destroy();
this.dropdown.destroy();
this.results.destroy();
this.dataAdapter = null;
this.selection = null;
this.dropdown = null;
this.results = null;
};
Select2.prototype.render = function () {
var $container = $(
'<span class="select2 select2-container">' +
'<span class="selection"></span>' +
'<span class="dropdown-wrapper" aria-hidden="true"></span>' +
'</span>'
);
$container.attr('dir', this.options.get('dir'));
this.$container = $container;
this.$container.addClass('select2-container--' + this.options.get('theme'));
Utils.StoreData($container[0], 'element', this.$element);
return $container;
};
return Select2;
});

View File

@ -1,110 +0,0 @@
define([
'./array',
'../utils',
'jquery'
], function (ArrayAdapter, Utils, $) {
function AjaxAdapter ($element, options) {
this.ajaxOptions = this._applyDefaults(options.get('ajax'));
if (this.ajaxOptions.processResults != null) {
this.processResults = this.ajaxOptions.processResults;
}
AjaxAdapter.__super__.constructor.call(this, $element, options);
}
Utils.Extend(AjaxAdapter, ArrayAdapter);
AjaxAdapter.prototype._applyDefaults = function (options) {
var defaults = {
data: function (params) {
return $.extend({}, params, {
q: params.term
});
},
transport: function (params, success, failure) {
var $request = $.ajax(params);
$request.then(success);
$request.fail(failure);
return $request;
}
};
return $.extend({}, defaults, options, true);
};
AjaxAdapter.prototype.processResults = function (results) {
return results;
};
AjaxAdapter.prototype.query = function (params, callback) {
var matches = [];
var self = this;
if (this._request != null) {
// JSONP requests cannot always be aborted
if ($.isFunction(this._request.abort)) {
this._request.abort();
}
this._request = null;
}
var options = $.extend({
type: 'GET'
}, this.ajaxOptions);
if (typeof options.url === 'function') {
options.url = options.url.call(this.$element, params);
}
if (typeof options.data === 'function') {
options.data = options.data.call(this.$element, params);
}
function request () {
var $request = options.transport(options, function (data) {
var results = self.processResults(data, params);
if (self.options.get('debug') && window.console && console.error) {
// Check to make sure that the response included a `results` key.
if (!results || !results.results || !$.isArray(results.results)) {
console.error(
'Select2: The AJAX results did not return an array in the ' +
'`results` key of the response.'
);
}
}
callback(results);
}, function () {
// Attempt to detect if a request was aborted
// Only works if the transport exposes a status property
if ('status' in $request &&
($request.status === 0 || $request.status === '0')) {
return;
}
self.trigger('results:message', {
message: 'errorLoading'
});
});
self._request = $request;
}
if (this.ajaxOptions.delay && params.term != null) {
if (this._queryTimeout) {
window.clearTimeout(this._queryTimeout);
}
this._queryTimeout = window.setTimeout(request, this.ajaxOptions.delay);
} else {
request();
}
};
return AjaxAdapter;
});

View File

@ -1,79 +0,0 @@
define([
'./select',
'../utils',
'jquery'
], function (SelectAdapter, Utils, $) {
function ArrayAdapter ($element, options) {
var data = options.get('data') || [];
ArrayAdapter.__super__.constructor.call(this, $element, options);
this.addOptions(this.convertToOptions(data));
}
Utils.Extend(ArrayAdapter, SelectAdapter);
ArrayAdapter.prototype.select = function (data) {
var $option = this.$element.find('option').filter(function (i, elm) {
return elm.value == data.id.toString();
});
if ($option.length === 0) {
$option = this.option(data);
this.addOptions($option);
}
ArrayAdapter.__super__.select.call(this, data);
};
ArrayAdapter.prototype.convertToOptions = function (data) {
var self = this;
var $existing = this.$element.find('option');
var existingIds = $existing.map(function () {
return self.item($(this)).id;
}).get();
var $options = [];
// Filter out all items except for the one passed in the argument
function onlyItem (item) {
return function () {
return $(this).val() == item.id;
};
}
for (var d = 0; d < data.length; d++) {
var item = this._normalizeItem(data[d]);
// Skip items which were pre-loaded, only merge the data
if ($.inArray(item.id, existingIds) >= 0) {
var $existingOption = $existing.filter(onlyItem(item));
var existingData = this.item($existingOption);
var newData = $.extend(true, {}, item, existingData);
var $newOption = this.option(newData);
$existingOption.replaceWith($newOption);
continue;
}
var $option = this.option(item);
if (item.children) {
var $children = this.convertToOptions(item.children);
Utils.appendMany($option, $children);
}
$options.push($option);
}
return $options;
};
return ArrayAdapter;
});

View File

@ -1,40 +0,0 @@
define([
'../utils'
], function (Utils) {
function BaseAdapter ($element, options) {
BaseAdapter.__super__.constructor.call(this);
}
Utils.Extend(BaseAdapter, Utils.Observable);
BaseAdapter.prototype.current = function (callback) {
throw new Error('The `current` method must be defined in child classes.');
};
BaseAdapter.prototype.query = function (params, callback) {
throw new Error('The `query` method must be defined in child classes.');
};
BaseAdapter.prototype.bind = function (container, $container) {
// Can be implemented in subclasses
};
BaseAdapter.prototype.destroy = function () {
// Can be implemented in subclasses
};
BaseAdapter.prototype.generateResultId = function (container, data) {
var id = container.id + '-result-';
id += Utils.generateChars(4);
if (data.id != null) {
id += '-' + data.id.toString();
} else {
id += '-' + Utils.generateChars(4);
}
return id;
};
return BaseAdapter;
});

View File

@ -1,31 +0,0 @@
define([
], function () {
function MaximumInputLength (decorated, $e, options) {
this.maximumInputLength = options.get('maximumInputLength');
decorated.call(this, $e, options);
}
MaximumInputLength.prototype.query = function (decorated, params, callback) {
params.term = params.term || '';
if (this.maximumInputLength > 0 &&
params.term.length > this.maximumInputLength) {
this.trigger('results:message', {
message: 'inputTooLong',
args: {
maximum: this.maximumInputLength,
input: params.term,
params: params
}
});
return;
}
decorated.call(this, params, callback);
};
return MaximumInputLength;
});

View File

@ -1,31 +0,0 @@
define([
], function (){
function MaximumSelectionLength (decorated, $e, options) {
this.maximumSelectionLength = options.get('maximumSelectionLength');
decorated.call(this, $e, options);
}
MaximumSelectionLength.prototype.query =
function (decorated, params, callback) {
var self = this;
this.current(function (currentData) {
var count = currentData != null ? currentData.length : 0;
if (self.maximumSelectionLength > 0 &&
count >= self.maximumSelectionLength) {
self.trigger('results:message', {
message: 'maximumSelected',
args: {
maximum: self.maximumSelectionLength
}
});
return;
}
decorated.call(self, params, callback);
});
};
return MaximumSelectionLength;
});

View File

@ -1,30 +0,0 @@
define([
], function () {
function MinimumInputLength (decorated, $e, options) {
this.minimumInputLength = options.get('minimumInputLength');
decorated.call(this, $e, options);
}
MinimumInputLength.prototype.query = function (decorated, params, callback) {
params.term = params.term || '';
if (params.term.length < this.minimumInputLength) {
this.trigger('results:message', {
message: 'inputTooShort',
args: {
minimum: this.minimumInputLength,
input: params.term,
params: params
}
});
return;
}
decorated.call(this, params, callback);
};
return MinimumInputLength;
});

View File

@ -1,285 +0,0 @@
define([
'./base',
'../utils',
'jquery'
], function (BaseAdapter, Utils, $) {
function SelectAdapter ($element, options) {
this.$element = $element;
this.options = options;
SelectAdapter.__super__.constructor.call(this);
}
Utils.Extend(SelectAdapter, BaseAdapter);
SelectAdapter.prototype.current = function (callback) {
var data = [];
var self = this;
this.$element.find(':selected').each(function () {
var $option = $(this);
var option = self.item($option);
data.push(option);
});
callback(data);
};
SelectAdapter.prototype.select = function (data) {
var self = this;
data.selected = true;
// If data.element is a DOM node, use it instead
if ($(data.element).is('option')) {
data.element.selected = true;
this.$element.trigger('change');
return;
}
if (this.$element.prop('multiple')) {
this.current(function (currentData) {
var val = [];
data = [data];
data.push.apply(data, currentData);
for (var d = 0; d < data.length; d++) {
var id = data[d].id;
if ($.inArray(id, val) === -1) {
val.push(id);
}
}
self.$element.val(val);
self.$element.trigger('change');
});
} else {
var val = data.id;
this.$element.val(val);
this.$element.trigger('change');
}
};
SelectAdapter.prototype.unselect = function (data) {
var self = this;
if (!this.$element.prop('multiple')) {
return;
}
data.selected = false;
if ($(data.element).is('option')) {
data.element.selected = false;
this.$element.trigger('change');
return;
}
this.current(function (currentData) {
var val = [];
for (var d = 0; d < currentData.length; d++) {
var id = currentData[d].id;
if (id !== data.id && $.inArray(id, val) === -1) {
val.push(id);
}
}
self.$element.val(val);
self.$element.trigger('change');
});
};
SelectAdapter.prototype.bind = function (container, $container) {
var self = this;
this.container = container;
container.on('select', function (params) {
self.select(params.data);
});
container.on('unselect', function (params) {
self.unselect(params.data);
});
};
SelectAdapter.prototype.destroy = function () {
// Remove anything added to child elements
this.$element.find('*').each(function () {
// Remove any custom data set by Select2
Utils.RemoveData(this);
});
};
SelectAdapter.prototype.query = function (params, callback) {
var data = [];
var self = this;
var $options = this.$element.children();
$options.each(function () {
var $option = $(this);
if (!$option.is('option') && !$option.is('optgroup')) {
return;
}
var option = self.item($option);
var matches = self.matches(params, option);
if (matches !== null) {
data.push(matches);
}
});
callback({
results: data
});
};
SelectAdapter.prototype.addOptions = function ($options) {
Utils.appendMany(this.$element, $options);
};
SelectAdapter.prototype.option = function (data) {
var option;
if (data.children) {
option = document.createElement('optgroup');
option.label = data.text;
} else {
option = document.createElement('option');
if (option.textContent !== undefined) {
option.textContent = data.text;
} else {
option.innerText = data.text;
}
}
if (data.id !== undefined) {
option.value = data.id;
}
if (data.disabled) {
option.disabled = true;
}
if (data.selected) {
option.selected = true;
}
if (data.title) {
option.title = data.title;
}
var $option = $(option);
var normalizedData = this._normalizeItem(data);
normalizedData.element = option;
// Override the option's data with the combined data
Utils.StoreData(option, 'data', normalizedData);
return $option;
};
SelectAdapter.prototype.item = function ($option) {
var data = {};
data = Utils.GetData($option[0], 'data');
if (data != null) {
return data;
}
if ($option.is('option')) {
data = {
id: $option.val(),
text: $option.text(),
disabled: $option.prop('disabled'),
selected: $option.prop('selected'),
title: $option.prop('title')
};
} else if ($option.is('optgroup')) {
data = {
text: $option.prop('label'),
children: [],
title: $option.prop('title')
};
var $children = $option.children('option');
var children = [];
for (var c = 0; c < $children.length; c++) {
var $child = $($children[c]);
var child = this.item($child);
children.push(child);
}
data.children = children;
}
data = this._normalizeItem(data);
data.element = $option[0];
Utils.StoreData($option[0], 'data', data);
return data;
};
SelectAdapter.prototype._normalizeItem = function (item) {
if (item !== Object(item)) {
item = {
id: item,
text: item
};
}
item = $.extend({}, {
text: ''
}, item);
var defaults = {
selected: false,
disabled: false
};
if (item.id != null) {
item.id = item.id.toString();
}
if (item.text != null) {
item.text = item.text.toString();
}
if (item._resultId == null && item.id && this.container != null) {
item._resultId = this.generateResultId(this.container, item);
}
return $.extend({}, defaults, item);
};
SelectAdapter.prototype.matches = function (params, data) {
var matcher = this.options.get('matcher');
return matcher(params, data);
};
return SelectAdapter;
});

View File

@ -1,128 +0,0 @@
define([
'jquery'
], function ($) {
function Tags (decorated, $element, options) {
var tags = options.get('tags');
var createTag = options.get('createTag');
if (createTag !== undefined) {
this.createTag = createTag;
}
var insertTag = options.get('insertTag');
if (insertTag !== undefined) {
this.insertTag = insertTag;
}
decorated.call(this, $element, options);
if ($.isArray(tags)) {
for (var t = 0; t < tags.length; t++) {
var tag = tags[t];
var item = this._normalizeItem(tag);
var $option = this.option(item);
this.$element.append($option);
}
}
}
Tags.prototype.query = function (decorated, params, callback) {
var self = this;
this._removeOldTags();
if (params.term == null || params.page != null) {
decorated.call(this, params, callback);
return;
}
function wrapper (obj, child) {
var data = obj.results;
for (var i = 0; i < data.length; i++) {
var option = data[i];
var checkChildren = (
option.children != null &&
!wrapper({
results: option.children
}, true)
);
var optionText = (option.text || '').toUpperCase();
var paramsTerm = (params.term || '').toUpperCase();
var checkText = optionText === paramsTerm;
if (checkText || checkChildren) {
if (child) {
return false;
}
obj.data = data;
callback(obj);
return;
}
}
if (child) {
return true;
}
var tag = self.createTag(params);
if (tag != null) {
var $option = self.option(tag);
$option.attr('data-select2-tag', true);
self.addOptions([$option]);
self.insertTag(data, tag);
}
obj.results = data;
callback(obj);
}
decorated.call(this, params, wrapper);
};
Tags.prototype.createTag = function (decorated, params) {
var term = $.trim(params.term);
if (term === '') {
return null;
}
return {
id: term,
text: term
};
};
Tags.prototype.insertTag = function (_, data, tag) {
data.unshift(tag);
};
Tags.prototype._removeOldTags = function (_) {
var tag = this._lastTag;
var $options = this.$element.find('option[data-select2-tag]');
$options.each(function () {
if (this.selected) {
return;
}
$(this).remove();
});
};
return Tags;
});

View File

@ -1,116 +0,0 @@
define([
'jquery'
], function ($) {
function Tokenizer (decorated, $element, options) {
var tokenizer = options.get('tokenizer');
if (tokenizer !== undefined) {
this.tokenizer = tokenizer;
}
decorated.call(this, $element, options);
}
Tokenizer.prototype.bind = function (decorated, container, $container) {
decorated.call(this, container, $container);
this.$search = container.dropdown.$search || container.selection.$search ||
$container.find('.select2-search__field');
};
Tokenizer.prototype.query = function (decorated, params, callback) {
var self = this;
function createAndSelect (data) {
// Normalize the data object so we can use it for checks
var item = self._normalizeItem(data);
// Check if the data object already exists as a tag
// Select it if it doesn't
var $existingOptions = self.$element.find('option').filter(function () {
return $(this).val() === item.id;
});
// If an existing option wasn't found for it, create the option
if (!$existingOptions.length) {
var $option = self.option(item);
$option.attr('data-select2-tag', true);
self._removeOldTags();
self.addOptions([$option]);
}
// Select the item, now that we know there is an option for it
select(item);
}
function select (data) {
self.trigger('select', {
data: data
});
}
params.term = params.term || '';
var tokenData = this.tokenizer(params, this.options, createAndSelect);
if (tokenData.term !== params.term) {
// Replace the search term if we have the search box
if (this.$search.length) {
this.$search.val(tokenData.term);
this.$search.focus();
}
params.term = tokenData.term;
}
decorated.call(this, params, callback);
};
Tokenizer.prototype.tokenizer = function (_, params, options, callback) {
var separators = options.get('tokenSeparators') || [];
var term = params.term;
var i = 0;
var createTag = this.createTag || function (params) {
return {
id: params.term,
text: params.term
};
};
while (i < term.length) {
var termChar = term[i];
if ($.inArray(termChar, separators) === -1) {
i++;
continue;
}
var part = term.substr(0, i);
var partParams = $.extend({}, params, {
term: part
});
var data = createTag(partParams);
if (data == null) {
i++;
continue;
}
callback(data);
// Reset the term to not include the tokenized portion
term = term.substr(i + 1) || '';
i = 0;
}
return {
term: term
};
};
return Tokenizer;
});

Some files were not shown because too many files have changed in this diff Show More