mirror of
https://gitlab.crans.org/bde/nk20
synced 2024-11-27 02:43:01 +00:00
Merge branch 'master' into tranfer_front
# Conflicts: # static/js/base.js
This commit is contained in:
commit
bac81cd13e
12
.coveragerc
12
.coveragerc
@ -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
|
|
24
.env_example
24
.env_example
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
24
README.md
24
README.md
@ -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
|
||||||
|
|
||||||
|
@ -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__'
|
||||||
|
@ -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)
|
||||||
|
@ -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', ]
|
||||||
|
20
apps/activity/fixtures/initial.json
Normal file
20
apps/activity/fixtures/initial.json
Normal 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
84
apps/activity/forms.py
Normal 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 ...',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
@ -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,
|
@property
|
||||||
)
|
def has_entry(self):
|
||||||
entry_transaction = models.ForeignKey(
|
try:
|
||||||
'note.Transaction',
|
if self.entry:
|
||||||
on_delete=models.PROTECT,
|
return True
|
||||||
blank=True,
|
return False
|
||||||
null=True,
|
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
108
apps/activity/tables.py
Normal 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
17
apps/activity/urls.py
Normal 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
161
apps/activity/views.py
Normal 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
|
@ -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")
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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'),
|
|
||||||
)
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -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",
|
|
||||||
))
|
|
||||||
|
@ -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')
|
||||||
|
@ -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()
|
||||||
|
@ -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
|
||||||
|
@ -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"),
|
|
||||||
]
|
]
|
||||||
|
@ -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})
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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
|
||||||
|
@ -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):
|
||||||
|
@ -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 ...',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
@ -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):
|
||||||
|
@ -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:
|
||||||
|
@ -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,9 +10,10 @@ 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
|
||||||
|
NoteUser.objects.get_or_create(user=instance)
|
||||||
instance.note.save()
|
instance.note.save()
|
||||||
|
|
||||||
|
|
||||||
|
@ -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',)
|
||||||
@ -119,8 +118,8 @@ class AliasTable(tables.Table):
|
|||||||
|
|
||||||
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)
|
||||||
|
@ -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)
|
|
||||||
|
@ -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'),
|
|
||||||
]
|
]
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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__'
|
||||||
|
@ -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)
|
||||||
|
@ -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', ]
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
59
apps/permission/decorators.py
Normal file
59
apps/permission/decorators.py
Normal 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
@ -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
|
||||||
|
if not hasattr(obj, "pk") or not obj.pk:
|
||||||
obj.pk = 0
|
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")
|
||||||
|
@ -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.
|
||||||
|
@ -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
|
||||||
|
@ -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
11
apps/permission/views.py
Normal 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"))
|
4
apps/registration/__init__.py
Normal file
4
apps/registration/__init__.py
Normal 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
10
apps/registration/apps.py
Normal 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')
|
80
apps/registration/forms.py
Normal file
80
apps/registration/forms.py
Normal 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,
|
||||||
|
)
|
0
apps/registration/migrations/__init__.py
Normal file
0
apps/registration/migrations/__init__.py
Normal file
26
apps/registration/tables.py
Normal file
26
apps/registration/tables.py
Normal 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
|
||||||
|
}
|
30
apps/registration/tokens.py
Normal file
30
apps/registration/tokens.py
Normal 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
18
apps/registration/urls.py
Normal 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
358
apps/registration/views.py
Normal 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')
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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")
|
||||||
|
@ -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
302
note_kfet/inputs.py
Normal 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
|
@ -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
|
||||||
|
@ -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 = [
|
||||||
|
@ -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
|
||||||
|
121
static/bootstrap_datepicker_plus/css/datepicker-widget.css
Normal file
121
static/bootstrap_datepicker_plus/css/datepicker-widget.css
Normal 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;
|
||||||
|
}
|
55
static/bootstrap_datepicker_plus/js/datepicker-widget.js
Normal file
55
static/bootstrap_datepicker_plus/js/datepicker-widget.js
Normal 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
37
static/js/autocomplete_model.js
Normal file
37
static/js/autocomplete_model.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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
|
||||||
|
@ -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/",
|
||||||
|
262
static/vendor/select2/Gruntfile.js
vendored
262
static/vendor/select2/Gruntfile.js
vendored
@ -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']);
|
|
||||||
};
|
|
13
static/vendor/select2/bower.json
vendored
13
static/vendor/select2/bower.json
vendored
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
19
static/vendor/select2/component.json
vendored
19
static/vendor/select2/component.json
vendored
@ -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"
|
|
||||||
}
|
|
22
static/vendor/select2/composer.json
vendored
22
static/vendor/select2/composer.json
vendored
@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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>
|
|
12
static/vendor/select2/docs/community.html
vendored
12
static/vendor/select2/docs/community.html
vendored
@ -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>
|
|
12
static/vendor/select2/docs/examples.html
vendored
12
static/vendor/select2/docs/examples.html
vendored
@ -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>
|
|
12
static/vendor/select2/docs/index.html
vendored
12
static/vendor/select2/docs/index.html
vendored
@ -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>
|
|
12
static/vendor/select2/docs/options-old.html
vendored
12
static/vendor/select2/docs/options-old.html
vendored
@ -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>
|
|
12
static/vendor/select2/docs/options.html
vendored
12
static/vendor/select2/docs/options.html
vendored
@ -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>
|
|
65
static/vendor/select2/package.json
vendored
65
static/vendor/select2/package.json
vendored
@ -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": {}
|
|
||||||
}
|
|
6
static/vendor/select2/src/js/banner.end.js
vendored
6
static/vendor/select2/src/js/banner.end.js
vendored
@ -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
|
|
||||||
};
|
|
||||||
}());
|
|
6
static/vendor/select2/src/js/banner.start.js
vendored
6
static/vendor/select2/src/js/banner.start.js
vendored
@ -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;
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
define([
|
|
||||||
'jquery'
|
|
||||||
], function ($) {
|
|
||||||
// Used to shim jQuery.mousewheel for non-full builds.
|
|
||||||
return $;
|
|
||||||
});
|
|
58
static/vendor/select2/src/js/jquery.select2.js
vendored
58
static/vendor/select2/src/js/jquery.select2.js
vendored
@ -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;
|
|
||||||
});
|
|
14
static/vendor/select2/src/js/jquery.shim.js
vendored
14
static/vendor/select2/src/js/jquery.shim.js
vendored
@ -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 _$;
|
|
||||||
});
|
|
@ -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;
|
|
||||||
});
|
|
@ -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;
|
|
||||||
});
|
|
@ -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;
|
|
||||||
});
|
|
@ -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;
|
|
||||||
});
|
|
@ -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;
|
|
||||||
});
|
|
@ -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;
|
|
||||||
});
|
|
@ -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
|
|
||||||
};
|
|
||||||
});
|
|
618
static/vendor/select2/src/js/select2/core.js
vendored
618
static/vendor/select2/src/js/select2/core.js
vendored
@ -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;
|
|
||||||
});
|
|
110
static/vendor/select2/src/js/select2/data/ajax.js
vendored
110
static/vendor/select2/src/js/select2/data/ajax.js
vendored
@ -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;
|
|
||||||
});
|
|
@ -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;
|
|
||||||
});
|
|
@ -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;
|
|
||||||
});
|
|
@ -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;
|
|
||||||
});
|
|
@ -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;
|
|
||||||
});
|
|
@ -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;
|
|
||||||
});
|
|
285
static/vendor/select2/src/js/select2/data/select.js
vendored
285
static/vendor/select2/src/js/select2/data/select.js
vendored
@ -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;
|
|
||||||
});
|
|
128
static/vendor/select2/src/js/select2/data/tags.js
vendored
128
static/vendor/select2/src/js/select2/data/tags.js
vendored
@ -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;
|
|
||||||
});
|
|
@ -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
Loading…
Reference in New Issue
Block a user