mirror of
https://gitlab.crans.org/bde/nk20
synced 2024-12-23 07:52:23 +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
|
||||
DJANGO_DEV_STORE_METHOD="sqllite"
|
||||
DJANGO_DB_HOST="localhost"
|
||||
DJANGO_DB_NAME="note_db"
|
||||
DJANGO_DB_USER="note"
|
||||
DJANGO_DB_PASSWORD="CHANGE_ME"
|
||||
DJANGO_DB_PORT=""
|
||||
DJANGO_SECRET_KEY="CHANGE_ME"
|
||||
DJANGO_SETTINGS_MODULE="note_kfet.settings"
|
||||
DOMAIN="localhost"
|
||||
CONTACT_EMAIL="tresorerie.bde@localhost"
|
||||
NOTE_URL="localhost"
|
||||
DJANGO_DEV_STORE_METHOD=sqllite
|
||||
DJANGO_DB_HOST=localhost
|
||||
DJANGO_DB_NAME=note_db
|
||||
DJANGO_DB_USER=note
|
||||
DJANGO_DB_PASSWORD=CHANGE_ME
|
||||
DJANGO_DB_PORT=
|
||||
DJANGO_SECRET_KEY=CHANGE_ME
|
||||
DJANGO_SETTINGS_MODULE=note_kfet.settings
|
||||
DOMAIN=localhost
|
||||
CONTACT_EMAIL=tresorerie.bde@localhost
|
||||
NOTE_URL=localhost
|
||||
|
@ -18,7 +18,6 @@ COPY . /code/
|
||||
|
||||
# Comment what is not needed
|
||||
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/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
|
||||
et on renseigne des secrets et des paramètres :
|
||||
|
||||
DJANGO_APP_STAGE="dev" # ou "prod"
|
||||
DJANGO_DEV_STORE_METHOD="sqllite" # ou "postgres"
|
||||
DJANGO_DB_HOST="localhost"
|
||||
DJANGO_DB_NAME="note_db"
|
||||
DJANGO_DB_USER="note"
|
||||
DJANGO_DB_PASSWORD="CHANGE_ME"
|
||||
DJANGO_DB_PORT=""
|
||||
DJANGO_SECRET_KEY="CHANGE_ME"
|
||||
DJANGO_SETTINGS_MODULE="note_kfet.settings"
|
||||
DOMAIN="localhost" # note.example.com
|
||||
CONTACT_EMAIL="tresorerie.bde@localhost"
|
||||
NOTE_URL="localhost" # serveur cas note.example.com si auto-hébergé.
|
||||
DJANGO_APP_STAGE=dev # ou "prod"
|
||||
DJANGO_DEV_STORE_METHOD=sqllite # ou "postgres"
|
||||
DJANGO_DB_HOST=localhost
|
||||
DJANGO_DB_NAME=note_db
|
||||
DJANGO_DB_USER=note
|
||||
DJANGO_DB_PASSWORD=CHANGE_ME
|
||||
DJANGO_DB_PORT=
|
||||
DJANGO_SECRET_KEY=CHANGE_ME
|
||||
DJANGO_SETTINGS_MODULE="note_kfet.settings
|
||||
DOMAIN=localhost # note.example.com
|
||||
CONTACT_EMAIL=tresorerie.bde@localhost
|
||||
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
|
||||
|
||||
|
@ -3,7 +3,7 @@
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from ..models import ActivityType, Activity, Guest
|
||||
from ..models import ActivityType, Activity, Guest, Entry, GuestTransaction
|
||||
|
||||
|
||||
class ActivityTypeSerializer(serializers.ModelSerializer):
|
||||
@ -37,3 +37,25 @@ class GuestSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Guest
|
||||
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
|
||||
# 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):
|
||||
@ -11,3 +11,4 @@ def register_activity_urls(router, path):
|
||||
router.register(path + '/activity', ActivityViewSet)
|
||||
router.register(path + '/type', ActivityTypeViewSet)
|
||||
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 api.viewsets import ReadProtectedModelViewSet
|
||||
|
||||
from .serializers import ActivityTypeSerializer, ActivitySerializer, GuestSerializer
|
||||
from ..models import ActivityType, Activity, Guest
|
||||
from .serializers import ActivityTypeSerializer, ActivitySerializer, GuestSerializer, EntrySerializer
|
||||
from ..models import ActivityType, Activity, Guest, Entry
|
||||
|
||||
|
||||
class ActivityTypeViewSet(ReadProtectedModelViewSet):
|
||||
@ -42,4 +42,16 @@ class GuestViewSet(ReadProtectedModelViewSet):
|
||||
queryset = Guest.objects.all()
|
||||
serializer_class = GuestSerializer
|
||||
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
|
||||
# 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.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from note.models import NoteUser, Transaction
|
||||
|
||||
|
||||
class ActivityType(models.Model):
|
||||
@ -44,39 +48,127 @@ class Activity(models.Model):
|
||||
verbose_name=_('name'),
|
||||
max_length=255,
|
||||
)
|
||||
|
||||
description = models.TextField(
|
||||
verbose_name=_('description'),
|
||||
)
|
||||
|
||||
activity_type = models.ForeignKey(
|
||||
ActivityType,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+',
|
||||
verbose_name=_('type'),
|
||||
)
|
||||
|
||||
creater = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name=_("user"),
|
||||
)
|
||||
|
||||
organizer = models.ForeignKey(
|
||||
'member.Club',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+',
|
||||
verbose_name=_('organizer'),
|
||||
)
|
||||
|
||||
attendees_club = models.ForeignKey(
|
||||
'member.Club',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+',
|
||||
verbose_name=_('attendees club'),
|
||||
)
|
||||
|
||||
date_start = models.DateTimeField(
|
||||
verbose_name=_('start date'),
|
||||
)
|
||||
|
||||
date_end = models.DateTimeField(
|
||||
verbose_name=_('end date'),
|
||||
)
|
||||
|
||||
valid = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('valid'),
|
||||
)
|
||||
|
||||
open = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('open'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("activity")
|
||||
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):
|
||||
"""
|
||||
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,
|
||||
related_name='+',
|
||||
)
|
||||
name = models.CharField(
|
||||
|
||||
last_name = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("last name"),
|
||||
)
|
||||
|
||||
first_name = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("first name"),
|
||||
)
|
||||
|
||||
inviter = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
NoteUser,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+',
|
||||
)
|
||||
entry = models.DateTimeField(
|
||||
null=True,
|
||||
)
|
||||
entry_transaction = models.ForeignKey(
|
||||
'note.Transaction',
|
||||
on_delete=models.PROTECT,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name='guests',
|
||||
verbose_name=_("inviter"),
|
||||
)
|
||||
|
||||
@property
|
||||
def has_entry(self):
|
||||
try:
|
||||
if self.entry:
|
||||
return True
|
||||
return False
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
|
||||
one_year = timedelta(days=365)
|
||||
|
||||
if not force_insert:
|
||||
if self.activity.date_start > datetime.now():
|
||||
raise ValidationError(_("You can't invite someone once the activity is started."))
|
||||
|
||||
if not self.activity.valid:
|
||||
raise ValidationError(_("This activity is not validated yet."))
|
||||
|
||||
qs = Guest.objects.filter(
|
||||
first_name=self.first_name,
|
||||
last_name=self.last_name,
|
||||
activity__date_start__gte=self.activity.date_start - one_year,
|
||||
)
|
||||
if len(qs) >= 5:
|
||||
raise ValidationError(_("This person has been already invited 5 times this year."))
|
||||
|
||||
qs = qs.filter(activity=self.activity)
|
||||
if qs.exists():
|
||||
raise ValidationError(_("This person is already invited."))
|
||||
|
||||
qs = Guest.objects.filter(inviter=self.inviter, activity=self.activity)
|
||||
if len(qs) >= 3:
|
||||
raise ValidationError(_("You can't invite more than 3 people to this activity."))
|
||||
|
||||
return super().save(force_insert, force_update, using, update_fields)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("guest")
|
||||
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):
|
||||
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:
|
||||
return
|
||||
|
||||
if hasattr(instance, "_no_log"):
|
||||
return
|
||||
|
||||
# noinspection PyProtectedMember
|
||||
previous = instance._previous
|
||||
|
||||
@ -106,6 +109,9 @@ def delete_object(sender, instance, **kwargs):
|
||||
if instance._meta.label_lower in EXCLUDED:
|
||||
return
|
||||
|
||||
if hasattr(instance, "_no_log"):
|
||||
return
|
||||
|
||||
# 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()
|
||||
|
||||
|
@ -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": {
|
||||
"name": "BDE",
|
||||
"email": "tresorerie.bde@example.com",
|
||||
"membership_fee": 500,
|
||||
"membership_duration": "396 00:00:00",
|
||||
"membership_start": "213 00:00:00",
|
||||
"membership_end": "273 00:00:00"
|
||||
"require_memberships": true,
|
||||
"membership_fee_paid": 500,
|
||||
"membership_fee_unpaid": 500,
|
||||
"membership_duration": 396,
|
||||
"membership_start": "2019-08-31",
|
||||
"membership_end": "2020-09-30"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -17,10 +19,13 @@
|
||||
"fields": {
|
||||
"name": "Kfet",
|
||||
"email": "tresorerie.bde@example.com",
|
||||
"membership_fee": 3500,
|
||||
"membership_duration": "396 00:00:00",
|
||||
"membership_start": "213 00:00:00",
|
||||
"membership_end": "273 00:00:00"
|
||||
"parent_club": 1,
|
||||
"require_memberships": true,
|
||||
"membership_fee_paid": 3500,
|
||||
"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
|
||||
# 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.contrib.auth.forms import UserCreationForm, AuthenticationForm
|
||||
from django.contrib.auth.forms import AuthenticationForm
|
||||
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 .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):
|
||||
"""
|
||||
A form for the extras field provided by the :model:`member.Profile` model.
|
||||
@ -40,21 +28,64 @@ class ProfileForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Profile
|
||||
fields = '__all__'
|
||||
exclude = ['user']
|
||||
exclude = ('user', 'email_confirmed', 'registration_valid', 'soge', )
|
||||
|
||||
|
||||
class ClubForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Club
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class AddMembersForm(forms.Form):
|
||||
class Meta:
|
||||
fields = ('',)
|
||||
widgets = {
|
||||
"membership_fee_paid": AmountInput(),
|
||||
"membership_fee_unpaid": AmountInput(),
|
||||
"parent_club": Autocomplete(
|
||||
Club,
|
||||
attrs={
|
||||
'api_url': '/api/members/club/',
|
||||
}
|
||||
),
|
||||
"membership_start": DatePickerInput(),
|
||||
"membership_end": DatePickerInput(),
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
model = Membership
|
||||
fields = ('user', 'roles', 'date_start')
|
||||
@ -63,35 +94,13 @@ class MembershipForm(forms.ModelForm):
|
||||
# et récupère les noms d'utilisateur valides
|
||||
widgets = {
|
||||
'user':
|
||||
autocomplete.ModelSelect2(
|
||||
url='member:user_autocomplete',
|
||||
Autocomplete(
|
||||
User,
|
||||
attrs={
|
||||
'data-placeholder': 'Nom ...',
|
||||
'data-minimum-input-length': 1,
|
||||
'api_url': '/api/user/',
|
||||
'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
|
||||
|
||||
import datetime
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.template import loader
|
||||
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 registration.tokens import email_validation_token
|
||||
from note.models import MembershipTransaction
|
||||
|
||||
|
||||
class Profile(models.Model):
|
||||
@ -43,6 +50,23 @@ class Profile(models.Model):
|
||||
)
|
||||
paid = models.BooleanField(
|
||||
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,
|
||||
)
|
||||
|
||||
@ -54,6 +78,17 @@ class Profile(models.Model):
|
||||
def get_absolute_url(self):
|
||||
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):
|
||||
"""
|
||||
@ -77,22 +112,43 @@ class Club(models.Model):
|
||||
)
|
||||
|
||||
# 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,
|
||||
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).'),
|
||||
)
|
||||
membership_start = models.DurationField(
|
||||
|
||||
membership_start = models.DateField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('membership start'),
|
||||
help_text=_('How long after January 1st the members can renew '
|
||||
'their membership.'),
|
||||
)
|
||||
membership_end = models.DurationField(
|
||||
|
||||
membership_end = models.DateField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('membership end'),
|
||||
help_text=_('How long the membership can last after January 1st '
|
||||
@ -100,6 +156,33 @@ class Club(models.Model):
|
||||
'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:
|
||||
verbose_name = _("club")
|
||||
verbose_name_plural = _("clubs")
|
||||
@ -114,9 +197,6 @@ class Club(models.Model):
|
||||
class Role(models.Model):
|
||||
"""
|
||||
Role that an :model:`auth.User` can have in a :model:`member.Club`
|
||||
|
||||
TODO: Integrate the right management, and create some standard Roles at the
|
||||
creation of the club.
|
||||
"""
|
||||
name = models.CharField(
|
||||
verbose_name=_('name'),
|
||||
@ -138,40 +218,101 @@ class Membership(models.Model):
|
||||
|
||||
"""
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
User,
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name=_("user"),
|
||||
)
|
||||
|
||||
club = models.ForeignKey(
|
||||
Club,
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name=_("club"),
|
||||
)
|
||||
roles = models.ForeignKey(
|
||||
|
||||
roles = models.ManyToManyField(
|
||||
Role,
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name=_("roles"),
|
||||
)
|
||||
|
||||
date_start = models.DateField(
|
||||
default=datetime.date.today,
|
||||
verbose_name=_('membership starts on'),
|
||||
)
|
||||
|
||||
date_end = models.DateField(
|
||||
verbose_name=_('membership ends on'),
|
||||
null=True,
|
||||
)
|
||||
|
||||
fee = models.PositiveIntegerField(
|
||||
verbose_name=_('fee'),
|
||||
)
|
||||
|
||||
def valid(self):
|
||||
"""
|
||||
A membership is valid if today is between the start and the end date.
|
||||
"""
|
||||
if self.date_end is not None:
|
||||
return self.date_start.toordinal() <= datetime.datetime.now().toordinal() < self.date_end.toordinal()
|
||||
else:
|
||||
return self.date_start.toordinal() <= datetime.datetime.now().toordinal()
|
||||
|
||||
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 not Membership.objects.filter(user=self.user, club=self.club.parent_club):
|
||||
raise ValidationError(_('User is not a member of the 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') + ' ' + 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)
|
||||
|
||||
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:
|
||||
verbose_name = _('membership')
|
||||
verbose_name_plural = _('memberships')
|
||||
|
@ -10,7 +10,7 @@ def save_user_profile(instance, created, raw, **_kwargs):
|
||||
# When provisionning data, do not try to autocreate
|
||||
return
|
||||
|
||||
if created:
|
||||
if created and instance.is_active:
|
||||
from .models import Profile
|
||||
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
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
from datetime import datetime
|
||||
|
||||
import django_tables2 as tables
|
||||
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):
|
||||
"""
|
||||
List all clubs.
|
||||
"""
|
||||
class Meta:
|
||||
attrs = {
|
||||
'class': 'table table-condensed table-striped table-hover'
|
||||
@ -23,8 +33,15 @@ class ClubTable(tables.Table):
|
||||
|
||||
|
||||
class UserTable(tables.Table):
|
||||
"""
|
||||
List all users.
|
||||
"""
|
||||
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:
|
||||
attrs = {
|
||||
@ -33,3 +50,82 @@ class UserTable(tables.Table):
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
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'
|
||||
urlpatterns = [
|
||||
path('signup/', views.UserCreateView.as_view(), name="signup"),
|
||||
path('club/', views.ClubListView.as_view(), name="club_list"),
|
||||
path('club/<int:pk>/', views.ClubDetailView.as_view(), name="club_detail"),
|
||||
path('club/<int:pk>/add_member/', views.ClubAddMemberView.as_view(), name="club_add_member"),
|
||||
path('club/create/', views.ClubCreateView.as_view(), name="club_create"),
|
||||
path('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('club/<int:pk>/', views.ClubDetailView.as_view(), name="club_detail"),
|
||||
path('club/<int:club_pk>/add_member/', views.ClubAddMemberView.as_view(), name="club_add_member"),
|
||||
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/<int:pk>', views.UserDetailView.as_view(), name="user_detail"),
|
||||
path('user/<int:pk>/update', views.UserUpdateView.as_view(), name="user_update_profile"),
|
||||
path('user/<int:pk>/update_pic', views.ProfilePictureUpdateView.as_view(), name="user_update_pic"),
|
||||
path('user/<int:pk>/aliases', views.ProfileAliasView.as_view(), name="user_alias"),
|
||||
path('user/<int:pk>/', views.UserDetailView.as_view(), name="user_detail"),
|
||||
path('user/<int:pk>/update/', views.UserUpdateView.as_view(), name="user_update_profile"),
|
||||
path('user/<int:pk>/update_pic/', views.ProfilePictureUpdateView.as_view(), name="user_update_pic"),
|
||||
path('user/<int:pk>/aliases/', views.ProfileAliasView.as_view(), name="user_alias"),
|
||||
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
|
||||
|
||||
import io
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from PIL import Image
|
||||
from dal import autocomplete
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.views import LoginView
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse_lazy
|
||||
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_tables2.views import SingleTableView
|
||||
from rest_framework.authtoken.models import Token
|
||||
from note.forms import ImageForm
|
||||
#from note.forms import AliasForm, ImageForm
|
||||
from note.models import Alias, NoteUser
|
||||
from note.models.transactions import Transaction
|
||||
from note.models import Alias, NoteUser, NoteSpecial
|
||||
from note.models.transactions import Transaction, SpecialTransaction
|
||||
from note.tables import HistoryTable, AliasTable
|
||||
from permission.backends import PermissionBackend
|
||||
from permission.views import ProtectQuerysetMixin
|
||||
|
||||
from .filters import UserFilter, UserFilterFormHelper
|
||||
from .forms import SignUpForm, ProfileForm, ClubForm, MembershipForm, MemberFormSet, FormSetHelper, \
|
||||
CustomAuthenticationForm
|
||||
from .models import Club, Membership
|
||||
from .tables import ClubTable, UserTable
|
||||
from .forms import ProfileForm, ClubForm, MembershipForm, CustomAuthenticationForm
|
||||
from .models import Club, Membership, Role
|
||||
from .tables import ClubTable, UserTable, MembershipTable
|
||||
|
||||
|
||||
class CustomLoginView(LoginView):
|
||||
"""
|
||||
Login view, where the user can select its permission mask.
|
||||
"""
|
||||
form_class = CustomAuthenticationForm
|
||||
|
||||
def form_valid(self, form):
|
||||
@ -42,33 +40,10 @@ class CustomLoginView(LoginView):
|
||||
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
|
||||
fields = ['first_name', 'last_name', 'username', 'email']
|
||||
template_name = 'member/profile_update.html'
|
||||
@ -77,14 +52,20 @@ class UserUpdateView(LoginRequiredMixin, UpdateView):
|
||||
|
||||
def get_context_data(self, **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['title'] = _("Update Profile")
|
||||
return context
|
||||
|
||||
def get_form(self, form_class=None):
|
||||
form = super().get_form(form_class)
|
||||
if 'username' not in form.data:
|
||||
return form
|
||||
def form_valid(self, form):
|
||||
new_username = form.data['username']
|
||||
# Si l'utilisateur cherche à modifier son pseudo, le nouveau pseudo ne doit pas être proche d'un alias existant
|
||||
note = NoteUser.objects.filter(
|
||||
@ -92,9 +73,8 @@ class UserUpdateView(LoginRequiredMixin, UpdateView):
|
||||
if note.exists() and note.get().user != self.object:
|
||||
form.add_error('username',
|
||||
_("An alias with a similar name already exists."))
|
||||
return form
|
||||
return super().form_invalid(form)
|
||||
|
||||
def form_valid(self, form):
|
||||
profile_form = ProfileForm(
|
||||
data=self.request.POST,
|
||||
instance=self.object.profile,
|
||||
@ -102,29 +82,35 @@ class UserUpdateView(LoginRequiredMixin, UpdateView):
|
||||
if form.is_valid() and profile_form.is_valid():
|
||||
new_username = form.data['username']
|
||||
alias = Alias.objects.filter(name=new_username)
|
||||
# Si le nouveau pseudo n'est pas un de nos alias, on supprime éventuellement un alias similaire pour le remplacer
|
||||
# Si le nouveau pseudo n'est pas un de nos alias,
|
||||
# on supprime éventuellement un alias similaire pour le remplacer
|
||||
if not alias.exists():
|
||||
similar = Alias.objects.filter(
|
||||
normalized_name=Alias.normalize(new_username))
|
||||
if similar.exists():
|
||||
similar.delete()
|
||||
|
||||
olduser = User.objects.get(pk=form.instance.pk)
|
||||
|
||||
user = form.save(commit=False)
|
||||
profile = profile_form.save(commit=False)
|
||||
profile.user = user
|
||||
profile.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)
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
if kwargs:
|
||||
return reverse_lazy('member:user_detail',
|
||||
kwargs={'pk': kwargs['id']})
|
||||
else:
|
||||
return reverse_lazy('member:user_detail', args=(self.object.id,))
|
||||
url = 'member:user_detail' if self.object.profile.registration_valid else 'registration:future_user_detail'
|
||||
return reverse_lazy(url, args=(self.object.id,))
|
||||
|
||||
|
||||
class UserDetailView(LoginRequiredMixin, DetailView):
|
||||
class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
Affiche les informations sur un utilisateur, sa note, ses clubs...
|
||||
"""
|
||||
@ -133,47 +119,77 @@ class UserDetailView(LoginRequiredMixin, DetailView):
|
||||
template_name = "member/profile_detail.html"
|
||||
|
||||
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):
|
||||
context = super().get_context_data(**kwargs)
|
||||
user = context['user_object']
|
||||
history_list = \
|
||||
Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note)).order_by("-id")
|
||||
context['history_list'] = HistoryTable(history_list)
|
||||
club_list = \
|
||||
Membership.objects.all().filter(user=user).only("club")
|
||||
context['club_list'] = ClubTable(club_list)
|
||||
Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note)).order_by("-id")\
|
||||
.filter(PermissionBackend.filter_queryset(self.request.user, Transaction, "view"))
|
||||
history_table = HistoryTable(history_list, prefix='transaction-')
|
||||
history_table.paginate(per_page=20, page=self.request.GET.get("transaction-page", 1))
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
table_class = UserTable
|
||||
template_name = 'member/user_list.html'
|
||||
filter_class = UserFilter
|
||||
formhelper_class = UserFilterFormHelper
|
||||
|
||||
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)
|
||||
self.filter.form.helper = self.formhelper_class()
|
||||
return self.filter.qs
|
||||
"""
|
||||
Filter the user list with the given pattern.
|
||||
"""
|
||||
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):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["filter"] = self.filter
|
||||
|
||||
context["title"] = _("Search user")
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class ProfileAliasView(LoginRequiredMixin, DetailView):
|
||||
|
||||
class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
View and manage user aliases.
|
||||
"""
|
||||
model = User
|
||||
template_name = 'member/profile_alias.html'
|
||||
context_object_name = 'user_object'
|
||||
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
note = context['object'].note
|
||||
@ -181,11 +197,14 @@ class ProfileAliasView(LoginRequiredMixin, DetailView):
|
||||
return context
|
||||
|
||||
|
||||
class PictureUpdateView(LoginRequiredMixin, FormMixin, DetailView):
|
||||
class PictureUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin, DetailView):
|
||||
"""
|
||||
Update profile picture of the user note.
|
||||
"""
|
||||
form_class = ImageForm
|
||||
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
context = super().get_context_data(*args, **kwargs)
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['form'] = self.form_class(self.request.POST, self.request.FILES)
|
||||
return context
|
||||
|
||||
@ -242,8 +261,7 @@ class ManageAuthTokens(LoginRequiredMixin, TemplateView):
|
||||
template_name = "member/manage_auth_tokens.html"
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if 'regenerate' in request.GET and Token.objects.filter(
|
||||
user=request.user).exists():
|
||||
if 'regenerate' in request.GET and Token.objects.filter(user=request.user).exists():
|
||||
Token.objects.get(user=self.request.user).delete()
|
||||
return redirect(reverse_lazy('member:auth_token') + "?show",
|
||||
permanent=True)
|
||||
@ -252,39 +270,16 @@ class ManageAuthTokens(LoginRequiredMixin, TemplateView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['token'] = Token.objects.get_or_create(
|
||||
user=self.request.user)[0]
|
||||
context['token'] = Token.objects.get_or_create(user=self.request.user)[0]
|
||||
return context
|
||||
|
||||
|
||||
class UserAutocomplete(autocomplete.Select2QuerySetView):
|
||||
"""
|
||||
Auto complete users by usernames
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Quand une personne cherche un utilisateur par pseudo, une requête est envoyée sur l'API dédiée à l'auto-complétion.
|
||||
Cette fonction récupère la requête, et renvoie la liste filtrée des utilisateurs par pseudos.
|
||||
"""
|
||||
# Un utilisateur non connecté n'a accès à aucune information
|
||||
if not self.request.user.is_authenticated:
|
||||
return User.objects.none()
|
||||
|
||||
qs = User.objects.filter(PermissionBackend.filter_queryset(self.request.user, User, "view")).all()
|
||||
|
||||
if self.q:
|
||||
qs = qs.filter(username__regex="^" + self.q)
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
# ******************************* #
|
||||
# CLUB #
|
||||
# ******************************* #
|
||||
|
||||
|
||||
class ClubCreateView(LoginRequiredMixin, CreateView):
|
||||
class ClubCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
|
||||
"""
|
||||
Create Club
|
||||
"""
|
||||
@ -294,43 +289,66 @@ class ClubCreateView(LoginRequiredMixin, CreateView):
|
||||
|
||||
def form_valid(self, form):
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class ClubListView(LoginRequiredMixin, SingleTableView):
|
||||
|
||||
class ClubListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
||||
"""
|
||||
List existing Clubs
|
||||
"""
|
||||
model = Club
|
||||
table_class = ClubTable
|
||||
|
||||
def get_queryset(self, **kwargs):
|
||||
return super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, Club, "view"))
|
||||
|
||||
|
||||
class ClubDetailView(LoginRequiredMixin, DetailView):
|
||||
class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
Display details of a club
|
||||
"""
|
||||
model = 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):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
club = context["club"]
|
||||
club_transactions = \
|
||||
Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note))
|
||||
context['history_list'] = HistoryTable(club_transactions)
|
||||
club_member = \
|
||||
Membership.objects.all().filter(club=club)
|
||||
# TODO: consider only valid Membership
|
||||
context['member_list'] = club_member
|
||||
if PermissionBackend.check_perm(self.request.user, "member.change_club_membership_start", club):
|
||||
club.update_membership_dates()
|
||||
|
||||
club_transactions = Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note))\
|
||||
.filter(PermissionBackend.filter_queryset(self.request.user, Transaction, "view")).order_by('-id')
|
||||
history_table = HistoryTable(club_transactions, prefix="history-")
|
||||
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
|
||||
|
||||
class ClubAliasView(LoginRequiredMixin, DetailView):
|
||||
|
||||
class ClubAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
Manage aliases of a club.
|
||||
"""
|
||||
model = Club
|
||||
template_name = 'member/club_alias.html'
|
||||
context_object_name = 'club'
|
||||
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
note = context['object'].note
|
||||
@ -338,15 +356,23 @@ class ClubAliasView(LoginRequiredMixin, DetailView):
|
||||
return context
|
||||
|
||||
|
||||
class ClubUpdateView(LoginRequiredMixin, UpdateView):
|
||||
class ClubUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
Update the information of a club.
|
||||
"""
|
||||
model = Club
|
||||
context_object_name = "club"
|
||||
form_class = ClubForm
|
||||
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):
|
||||
"""
|
||||
Update the profile picture of a club.
|
||||
"""
|
||||
model = Club
|
||||
template_name = 'member/club_picture_update.html'
|
||||
context_object_name = 'club'
|
||||
@ -355,34 +381,229 @@ class ClubPictureUpdateView(PictureUpdateView):
|
||||
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
|
||||
form_class = MembershipForm
|
||||
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):
|
||||
club = Club.objects.get(pk=self.kwargs["pk"])
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['formset'] = MemberFormSet()
|
||||
context['helper'] = FormSetHelper()
|
||||
form = context['form']
|
||||
|
||||
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['no_cache'] = True
|
||||
|
||||
return context
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
return
|
||||
# TODO: Implement POST
|
||||
# formset = MembershipFormset(request.POST)
|
||||
# if formset.is_valid():
|
||||
# return self.form_valid(formset)
|
||||
# else:
|
||||
# return self.form_invalid(formset)
|
||||
def form_valid(self, form):
|
||||
"""
|
||||
Create membership, check that all is good, make transactions
|
||||
"""
|
||||
# Get the club that is concerned by the membership
|
||||
if "club_pk" in self.kwargs:
|
||||
club = Club.objects.filter(PermissionBackend.filter_queryset(self.request.user, Club, "view")) \
|
||||
.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):
|
||||
formset.save()
|
||||
return super().form_valid(formset)
|
||||
form.instance.club = club
|
||||
|
||||
# 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.transactions import Transaction, TemplateCategory, TransactionTemplate, \
|
||||
RecurrentTransaction, MembershipTransaction
|
||||
RecurrentTransaction, MembershipTransaction, SpecialTransaction
|
||||
|
||||
|
||||
class AliasInlines(admin.TabularInline):
|
||||
@ -102,7 +102,7 @@ class TransactionAdmin(PolymorphicParentModelAdmin):
|
||||
"""
|
||||
Admin customisation for Transaction
|
||||
"""
|
||||
child_models = (RecurrentTransaction, MembershipTransaction)
|
||||
child_models = (RecurrentTransaction, MembershipTransaction, SpecialTransaction)
|
||||
list_display = ('created_at', 'poly_source', 'poly_destination',
|
||||
'quantity', 'amount', 'valid')
|
||||
list_filter = ('valid',)
|
||||
@ -138,6 +138,20 @@ class TransactionAdmin(PolymorphicParentModelAdmin):
|
||||
return []
|
||||
|
||||
|
||||
@admin.register(MembershipTransaction)
|
||||
class MembershipTransactionAdmin(PolymorphicChildModelAdmin):
|
||||
"""
|
||||
Admin customisation for MembershipTransaction
|
||||
"""
|
||||
|
||||
|
||||
@admin.register(SpecialTransaction)
|
||||
class SpecialTransactionAdmin(PolymorphicChildModelAdmin):
|
||||
"""
|
||||
Admin customisation for SpecialTransaction
|
||||
"""
|
||||
|
||||
|
||||
@admin.register(TransactionTemplate)
|
||||
class TransactionTemplateAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
|
@ -90,7 +90,7 @@ class NotePolymorphicSerializer(PolymorphicSerializer):
|
||||
Note: NoteSerializer,
|
||||
NoteUser: NoteUserSerializer,
|
||||
NoteClub: NoteClubSerializer,
|
||||
NoteSpecial: NoteSpecialSerializer
|
||||
NoteSpecial: NoteSpecialSerializer,
|
||||
}
|
||||
|
||||
class Meta:
|
||||
@ -177,6 +177,7 @@ class SpecialTransactionSerializer(serializers.ModelSerializer):
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
# noinspection PyUnresolvedReferences
|
||||
class TransactionPolymorphicSerializer(PolymorphicSerializer):
|
||||
model_serializer_mapping = {
|
||||
Transaction: TransactionSerializer,
|
||||
@ -185,5 +186,12 @@ class TransactionPolymorphicSerializer(PolymorphicSerializer):
|
||||
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:
|
||||
model = Transaction
|
||||
|
@ -8,7 +8,6 @@ from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet
|
||||
|
||||
from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer,\
|
||||
@ -25,7 +24,8 @@ class NotePolymorphicViewSet(ReadOnlyProtectedModelViewSet):
|
||||
"""
|
||||
queryset = Note.objects.all()
|
||||
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', ]
|
||||
ordering_fields = ['alias__name', 'alias__normalized_name']
|
||||
|
||||
@ -60,19 +60,19 @@ class AliasViewSet(ReadProtectedModelViewSet):
|
||||
def get_serializer_class(self):
|
||||
serializer_class = self.serializer_class
|
||||
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',))
|
||||
return serializer_class
|
||||
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
try:
|
||||
self.perform_destroy(instance)
|
||||
except ValidationError as 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)
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Parse query and apply filters.
|
||||
|
@ -1,12 +1,12 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from dal import autocomplete
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from note_kfet.inputs import Autocomplete
|
||||
|
||||
from .models import Alias
|
||||
from .models import TransactionTemplate
|
||||
from .models import TransactionTemplate, NoteClub
|
||||
|
||||
|
||||
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}
|
||||
widgets = {
|
||||
'destination':
|
||||
autocomplete.ModelSelect2(
|
||||
url='note:note_autocomplete',
|
||||
Autocomplete(
|
||||
NoteClub,
|
||||
attrs={
|
||||
'data-placeholder': 'Note ...',
|
||||
'data-minimum-input-length': 1,
|
||||
'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(NoteClub).pk),
|
||||
'placeholder': 'Note ...',
|
||||
},
|
||||
),
|
||||
}
|
||||
|
@ -242,10 +242,10 @@ class Alias(models.Model):
|
||||
pass
|
||||
self.normalized_name = normalized_name
|
||||
|
||||
def save(self,*args,**kwargs):
|
||||
def save(self, *args, **kwargs):
|
||||
self.normalized_name = self.normalize(self.name)
|
||||
super().save(*args,**kwargs)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
if self.name == str(self.note):
|
||||
raise ValidationError(_("You can't delete your main alias."),
|
||||
|
@ -2,7 +2,6 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import F
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@ -47,12 +46,14 @@ class TransactionTemplate(models.Model):
|
||||
unique=True,
|
||||
error_messages={'unique': _("A template with this name already exist")},
|
||||
)
|
||||
|
||||
destination = models.ForeignKey(
|
||||
NoteClub,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+', # no reverse
|
||||
verbose_name=_('destination'),
|
||||
)
|
||||
|
||||
amount = models.PositiveIntegerField(
|
||||
verbose_name=_('amount'),
|
||||
help_text=_('in centimes'),
|
||||
@ -63,9 +64,12 @@ class TransactionTemplate(models.Model):
|
||||
verbose_name=_('type'),
|
||||
max_length=31,
|
||||
)
|
||||
|
||||
display = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("display"),
|
||||
)
|
||||
|
||||
description = models.CharField(
|
||||
verbose_name=_('description'),
|
||||
max_length=255,
|
||||
@ -141,6 +145,7 @@ class Transaction(PolymorphicModel):
|
||||
max_length=255,
|
||||
default=None,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
@ -2,7 +2,7 @@
|
||||
# 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
|
||||
"""
|
||||
@ -10,10 +10,11 @@ def save_user_note(instance, created, raw, **_kwargs):
|
||||
# When provisionning data, do not try to autocreate
|
||||
return
|
||||
|
||||
if created:
|
||||
from .models import NoteUser
|
||||
NoteUser.objects.create(user=instance)
|
||||
instance.note.save()
|
||||
if (instance.is_superuser or instance.profile.registration_valid) and instance.is_active:
|
||||
# Create note only when the registration is validated
|
||||
from note.models import NoteUser
|
||||
NoteUser.objects.get_or_create(user=instance)
|
||||
instance.note.save()
|
||||
|
||||
|
||||
def save_club_note(instance, created, raw, **_kwargs):
|
||||
|
@ -106,9 +106,8 @@ DELETE_TEMPLATE = """
|
||||
class AliasTable(tables.Table):
|
||||
class Meta:
|
||||
attrs = {
|
||||
'class':
|
||||
'table table condensed table-striped table-hover',
|
||||
'id':"alias_table"
|
||||
'class': 'table table condensed table-striped table-hover',
|
||||
'id': "alias_table"
|
||||
}
|
||||
model = Alias
|
||||
fields = ('name',)
|
||||
@ -118,9 +117,9 @@ class AliasTable(tables.Table):
|
||||
name = tables.Column(attrs={'td': {'class': 'text-center'}})
|
||||
|
||||
delete_col = tables.TemplateColumn(template_code=DELETE_TEMPLATE,
|
||||
extra_context={"delete_trans": _('delete')},
|
||||
attrs={'td': {'class': 'col-sm-1'}})
|
||||
|
||||
extra_context={"delete_trans": _('delete')},
|
||||
attrs={'td': {'class': 'col-sm-1'}},
|
||||
verbose_name=_("Delete"),)
|
||||
|
||||
|
||||
class ButtonTable(tables.Table):
|
||||
@ -136,17 +135,20 @@ class ButtonTable(tables.Table):
|
||||
}
|
||||
|
||||
model = TransactionTemplate
|
||||
exclude = ('id',)
|
||||
|
||||
edit = tables.LinkColumn('note:template_update',
|
||||
args=[A('pk')],
|
||||
attrs={'td': {'class': 'col-sm-1'},
|
||||
'a': {'class': 'btn btn-sm btn-primary'}},
|
||||
text=_('edit'),
|
||||
accessor='pk')
|
||||
accessor='pk',
|
||||
verbose_name=_("Edit"),)
|
||||
|
||||
delete_col = tables.TemplateColumn(template_code=DELETE_TEMPLATE,
|
||||
extra_context={"delete_trans": _('delete')},
|
||||
attrs={'td': {'class': 'col-sm-1'}})
|
||||
extra_context={"delete_trans": _('delete')},
|
||||
attrs={'td': {'class': 'col-sm-1'}},
|
||||
verbose_name=_("Delete"),)
|
||||
|
||||
def render_amount(self, 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.filter('pretty_money', pretty_money)
|
||||
register.filter('cents_to_euros', cents_to_euros)
|
||||
|
@ -4,7 +4,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
from .models import Note
|
||||
|
||||
app_name = 'note'
|
||||
urlpatterns = [
|
||||
@ -13,7 +12,4 @@ urlpatterns = [
|
||||
path('buttons/update/<int:pk>/', views.TransactionTemplateUpdateView.as_view(), name='template_update'),
|
||||
path('buttons/', views.TransactionTemplateListView.as_view(), name='template_list'),
|
||||
path('consos/', views.ConsoView.as_view(), name='consos'),
|
||||
|
||||
# API for the note autocompleter
|
||||
path('note-autocomplete/', views.NoteAutocomplete.as_view(model=Note), name='note_autocomplete'),
|
||||
]
|
||||
|
@ -1,23 +1,24 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# 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.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import CreateView, UpdateView
|
||||
from django_tables2 import SingleTableView
|
||||
from django.urls import reverse_lazy
|
||||
from note_kfet.inputs import AmountInput
|
||||
from permission.backends import PermissionBackend
|
||||
from permission.views import ProtectQuerysetMixin
|
||||
|
||||
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 .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`.
|
||||
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
|
||||
# Transaction history table
|
||||
table_class = HistoryTable
|
||||
table_pagination = {"per_page": 50}
|
||||
|
||||
def get_queryset(self):
|
||||
return Transaction.objects.filter(PermissionBackend.filter_queryset(
|
||||
self.request.user, Transaction, "view")
|
||||
).order_by("-id").all()[:50]
|
||||
def get_queryset(self, **kwargs):
|
||||
return super().get_queryset(**kwargs).order_by("-id").all()[:20]
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""
|
||||
@ -40,109 +38,62 @@ class TransactionCreateView(LoginRequiredMixin, SingleTableView):
|
||||
"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['title'] = _('Transfer money')
|
||||
context['amount_widget'] = AmountInput(attrs={"id": "amount"})
|
||||
context['polymorphic_ctype'] = ContentType.objects.get_for_model(Transaction).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
|
||||
|
||||
|
||||
class NoteAutocomplete(autocomplete.Select2QuerySetView):
|
||||
class TransactionTemplateCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
|
||||
"""
|
||||
Auto complete note by aliases. Used in every search field for note
|
||||
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
|
||||
Create Transaction template
|
||||
"""
|
||||
model = TransactionTemplate
|
||||
form_class = TransactionTemplateForm
|
||||
success_url = reverse_lazy('note:template_list')
|
||||
|
||||
|
||||
class TransactionTemplateListView(LoginRequiredMixin, SingleTableView):
|
||||
class TransactionTemplateListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
||||
"""
|
||||
List TransactionsTemplates
|
||||
List Transaction templates
|
||||
"""
|
||||
model = TransactionTemplate
|
||||
table_class = ButtonTable
|
||||
|
||||
|
||||
class TransactionTemplateUpdateView(LoginRequiredMixin, UpdateView):
|
||||
class TransactionTemplateUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
Update Transaction template
|
||||
"""
|
||||
model = TransactionTemplate
|
||||
form_class = TransactionTemplateForm
|
||||
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.
|
||||
(Most of the magic happens in the dark world of Javascript see consos.js)
|
||||
"""
|
||||
model = Transaction
|
||||
template_name = "note/conso_form.html"
|
||||
|
||||
# Transaction history table
|
||||
table_class = HistoryTable
|
||||
table_pagination = {"per_page": 50}
|
||||
|
||||
def get_queryset(self):
|
||||
return Transaction.objects.filter(
|
||||
PermissionBackend.filter_queryset(self.request.user, Transaction, "view")
|
||||
).order_by("-id").all()[:50]
|
||||
def get_queryset(self, **kwargs):
|
||||
return super().get_queryset(**kwargs).order_by("-id").all()[:20]
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""
|
||||
|
@ -3,7 +3,7 @@
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from ..models import Permission
|
||||
from ..models import Permission, RolePermissions
|
||||
|
||||
|
||||
class PermissionSerializer(serializers.ModelSerializer):
|
||||
@ -15,3 +15,14 @@ class PermissionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Permission
|
||||
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
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from .views import PermissionViewSet
|
||||
from .views import PermissionViewSet, RolePermissionsViewSet
|
||||
|
||||
|
||||
def register_permission_urls(router, path):
|
||||
"""
|
||||
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 api.viewsets import ReadOnlyProtectedModelViewSet
|
||||
|
||||
from .serializers import PermissionSerializer
|
||||
from ..models import Permission
|
||||
from .serializers import PermissionSerializer, RolePermissionsSerializer
|
||||
from ..models import Permission, RolePermissions
|
||||
|
||||
|
||||
class PermissionViewSet(ReadOnlyProtectedModelViewSet):
|
||||
"""
|
||||
REST API View set.
|
||||
The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer,
|
||||
then render it on /api/logs/
|
||||
The djangorestframework plugin will get all `Permission` objects, serialize it to JSON with the given serializer,
|
||||
then render it on /api/permission/permission/
|
||||
"""
|
||||
queryset = Permission.objects.all()
|
||||
serializer_class = PermissionSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
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
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import datetime
|
||||
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
from django.contrib.auth.models import User, AnonymousUser
|
||||
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 member.models import Membership, Club
|
||||
|
||||
from .decorators import memoize
|
||||
from .models import Permission
|
||||
|
||||
|
||||
@ -20,6 +23,28 @@ class PermissionBackend(ModelBackend):
|
||||
supports_anonymous_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
|
||||
def permissions(user, model, type):
|
||||
"""
|
||||
@ -29,16 +54,16 @@ class PermissionBackend(ModelBackend):
|
||||
:param type: The type of the permissions: view, change, add or delete
|
||||
:return: A generator of the requested permissions
|
||||
"""
|
||||
for permission in Permission.objects.annotate(club=F("rolepermissions__role__membership__club")) \
|
||||
.filter(
|
||||
rolepermissions__role__membership__user=user,
|
||||
model__app_label=model.app_label, # For polymorphic models, we don't filter on model type
|
||||
type=type,
|
||||
).all():
|
||||
if not isinstance(model, permission.model.__class__):
|
||||
clubs = {}
|
||||
|
||||
for permission in PermissionBackend.get_raw_permissions(user, type):
|
||||
if not isinstance(model.model_class()(), permission.model.model_class()) or not permission.club:
|
||||
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(
|
||||
user=user,
|
||||
club=club,
|
||||
@ -52,10 +77,10 @@ class PermissionBackend(ModelBackend):
|
||||
F=F,
|
||||
Q=Q
|
||||
)
|
||||
if permission.mask.rank <= get_current_session().get("permission_mask", 0):
|
||||
yield permission
|
||||
yield permission
|
||||
|
||||
@staticmethod
|
||||
@memoize
|
||||
def filter_queryset(user, model, t, field=None):
|
||||
"""
|
||||
Filter a queryset by considering the permissions of a given user.
|
||||
@ -89,10 +114,23 @@ class PermissionBackend(ModelBackend):
|
||||
query = query | perm.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):
|
||||
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:
|
||||
return True
|
||||
|
||||
@ -104,10 +142,13 @@ class PermissionBackend(ModelBackend):
|
||||
perm_field = perm[2] if len(perm) == 3 else None
|
||||
ct = ContentType.objects.get_for_model(obj)
|
||||
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 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):
|
||||
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:
|
||||
self.update_query()
|
||||
|
||||
# Don't increase indexes
|
||||
obj.pk = 0
|
||||
# Don't increase indexes, if the primary key is an AutoField
|
||||
if not hasattr(obj, "pk") or not obj.pk:
|
||||
obj.pk = 0
|
||||
oldpk = None
|
||||
else:
|
||||
oldpk = obj.pk
|
||||
# Ensure previous models are deleted
|
||||
self.model.model_class().objects.filter(pk=obj.pk).annotate(_force_delete=F("pk")).delete()
|
||||
# Force insertion, no data verification, no trigger
|
||||
obj._force_save = 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
|
||||
obj._force_delete = True
|
||||
Model.delete(obj)
|
||||
|
||||
# If the primary key was specified, we restore it
|
||||
obj.pk = oldpk
|
||||
return ret
|
||||
|
||||
if permission_type == self.type:
|
||||
if self.field and field_name != self.field:
|
||||
return False
|
||||
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:
|
||||
return False
|
||||
|
||||
@ -93,6 +106,10 @@ class PermissionMask(models.Model):
|
||||
def __str__(self):
|
||||
return self.description
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("permission mask")
|
||||
verbose_name_plural = _("permission masks")
|
||||
|
||||
|
||||
class Permission(models.Model):
|
||||
|
||||
@ -140,6 +157,8 @@ class Permission(models.Model):
|
||||
|
||||
class Meta:
|
||||
unique_together = ('model', 'query', 'type', 'field')
|
||||
verbose_name = _("permission")
|
||||
verbose_name_plural = _("permissions")
|
||||
|
||||
def clean(self):
|
||||
self.query = json.dumps(json.loads(self.query))
|
||||
@ -280,3 +299,7 @@ class RolePermissions(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
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)
|
||||
# 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
|
||||
# they have read permissions to see 403, or not, and simply see
|
||||
# a 404 response.
|
||||
|
@ -2,8 +2,6 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
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 permission.backends import PermissionBackend
|
||||
|
||||
@ -29,6 +27,9 @@ def pre_save_object(sender, instance, **kwargs):
|
||||
if instance._meta.label_lower in EXCLUDED:
|
||||
return
|
||||
|
||||
if hasattr(instance, "_force_save"):
|
||||
return
|
||||
|
||||
user = get_current_authenticated_user()
|
||||
if user is None:
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# 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 old_value == new_value:
|
||||
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
|
||||
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
|
||||
has_perm = PermissionBackend().has_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)
|
||||
has_perm = PermissionBackend.check_perm(user, app_label + ".add_" + model_name, instance)
|
||||
|
||||
if not has_perm:
|
||||
raise PermissionDenied
|
||||
|
||||
|
||||
def pre_delete_object(sender, instance, **kwargs):
|
||||
def pre_delete_object(instance, **kwargs):
|
||||
"""
|
||||
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:
|
||||
return
|
||||
|
||||
if hasattr(instance, "_force_delete"):
|
||||
return
|
||||
|
||||
user = get_current_authenticated_user()
|
||||
if user is None:
|
||||
# Action performed on shell is always granted
|
||||
@ -101,5 +87,5 @@ def pre_delete_object(sender, instance, **kwargs):
|
||||
model_name = model_name_full[1]
|
||||
|
||||
# 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
|
||||
|
@ -4,6 +4,7 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.template.defaultfilters import stringfilter
|
||||
from django import template
|
||||
from note.models import Transaction
|
||||
from note_kfet.middlewares import get_current_authenticated_user, get_current_session
|
||||
from permission.backends import PermissionBackend
|
||||
|
||||
@ -19,13 +20,8 @@ def not_empty_model_list(model_name):
|
||||
return False
|
||||
elif user.is_superuser and session.get("permission_mask", 0) >= 42:
|
||||
return True
|
||||
if session.get("not_empty_model_list_" + model_name, None):
|
||||
return session.get("not_empty_model_list_" + model_name, None) == 1
|
||||
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
|
||||
qs = model_list(model_name)
|
||||
return qs.exists()
|
||||
|
||||
|
||||
@stringfilter
|
||||
@ -39,15 +35,54 @@ def not_empty_model_change_list(model_name):
|
||||
return False
|
||||
elif user.is_superuser and session.get("permission_mask", 0) >= 42:
|
||||
return True
|
||||
if session.get("not_empty_model_change_list_" + model_name, None):
|
||||
return session.get("not_empty_model_change_list_" + model_name, None) == 1
|
||||
qs = model_list(model_name, "change")
|
||||
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(".")
|
||||
ct = ContentType.objects.get(app_label=spl[0], model=spl[1])
|
||||
qs = ct.model_class().objects.filter(PermissionBackend.filter_queryset(user, ct, "change"))
|
||||
session["not_empty_model_change_list_" + model_name] = 1 if qs.exists() else 2
|
||||
return session.get("not_empty_model_change_list_" + model_name) == 1
|
||||
qs = ct.model_class().objects.filter(PermissionBackend.filter_queryset(user, ct, t)).all()
|
||||
return qs
|
||||
|
||||
|
||||
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.filter('not_empty_model_list', not_empty_model_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 django import forms
|
||||
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
|
||||
|
||||
@ -19,7 +21,7 @@ class InvoiceForm(forms.ModelForm):
|
||||
# Django forms don't support date fields. We have to add it manually
|
||||
date = forms.DateField(
|
||||
initial=datetime.date.today,
|
||||
widget=forms.TextInput(attrs={'type': 'date'})
|
||||
widget=DatePickerInput()
|
||||
)
|
||||
|
||||
def clean_date(self):
|
||||
@ -30,19 +32,28 @@ class InvoiceForm(forms.ModelForm):
|
||||
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
|
||||
# its products. The FormSet will search automatically the ForeignKey in the Product model.
|
||||
ProductFormSet = forms.inlineformset_factory(
|
||||
Invoice,
|
||||
Product,
|
||||
fields='__all__',
|
||||
form=ProductForm,
|
||||
extra=1,
|
||||
)
|
||||
|
||||
|
||||
class ProductFormSetHelper(FormHelper):
|
||||
"""
|
||||
Specify some template informations for the product form.
|
||||
Specify some template information for the product form.
|
||||
"""
|
||||
|
||||
def __init__(self, form=None):
|
||||
@ -121,7 +132,8 @@ class LinkTransactionToRemittanceForm(forms.ModelForm):
|
||||
# Add submit button
|
||||
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):
|
||||
"""
|
||||
|
@ -59,6 +59,10 @@ class Invoice(models.Model):
|
||||
verbose_name=_("Acquitted"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("invoice")
|
||||
verbose_name_plural = _("invoices")
|
||||
|
||||
|
||||
class Product(models.Model):
|
||||
"""
|
||||
@ -95,6 +99,10 @@ class Product(models.Model):
|
||||
def total_euros(self):
|
||||
return self.total / 100
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("product")
|
||||
verbose_name_plural = _("products")
|
||||
|
||||
|
||||
class RemittanceType(models.Model):
|
||||
"""
|
||||
@ -109,6 +117,10 @@ class RemittanceType(models.Model):
|
||||
def __str__(self):
|
||||
return str(self.note)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("remittance type")
|
||||
verbose_name_plural = _("remittance types")
|
||||
|
||||
|
||||
class Remittance(models.Model):
|
||||
"""
|
||||
@ -136,6 +148,10 @@ class Remittance(models.Model):
|
||||
verbose_name=_("Closed"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("remittance")
|
||||
verbose_name_plural = _("remittances")
|
||||
|
||||
@property
|
||||
def transactions(self):
|
||||
"""
|
||||
@ -187,3 +203,7 @@ class SpecialTransactionProxy(models.Model):
|
||||
null=True,
|
||||
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 note.models import SpecialTransaction, NoteSpecial
|
||||
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 .models import Invoice, Product, Remittance, SpecialTransactionProxy
|
||||
from .tables import InvoiceTable, RemittanceTable, SpecialTransactionTable
|
||||
|
||||
|
||||
class InvoiceCreateView(LoginRequiredMixin, CreateView):
|
||||
class InvoiceCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
|
||||
"""
|
||||
Create Invoice
|
||||
"""
|
||||
@ -50,18 +52,8 @@ class InvoiceCreateView(LoginRequiredMixin, CreateView):
|
||||
def form_valid(self, 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
|
||||
formset = ProductFormSet(kwargs, instance=form.instance)
|
||||
formset = ProductFormSet(self.request.POST, instance=form.instance)
|
||||
if formset.is_valid():
|
||||
for f in formset:
|
||||
# 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')
|
||||
|
||||
|
||||
class InvoiceListView(LoginRequiredMixin, SingleTableView):
|
||||
class InvoiceListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
||||
"""
|
||||
List existing Invoices
|
||||
"""
|
||||
@ -85,7 +77,7 @@ class InvoiceListView(LoginRequiredMixin, SingleTableView):
|
||||
table_class = InvoiceTable
|
||||
|
||||
|
||||
class InvoiceUpdateView(LoginRequiredMixin, UpdateView):
|
||||
class InvoiceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
Create Invoice
|
||||
"""
|
||||
@ -112,16 +104,7 @@ class InvoiceUpdateView(LoginRequiredMixin, UpdateView):
|
||||
def form_valid(self, 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
|
||||
|
||||
formset = ProductFormSet(kwargs, instance=form.instance)
|
||||
formset = ProductFormSet(self.request.POST, instance=form.instance)
|
||||
saved = []
|
||||
# For each product, we save it
|
||||
if formset.is_valid():
|
||||
@ -149,7 +132,7 @@ class InvoiceRenderView(LoginRequiredMixin, View):
|
||||
|
||||
def get(self, request, **kwargs):
|
||||
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()
|
||||
|
||||
# Informations of the BDE. Should be updated when the school will move.
|
||||
@ -207,7 +190,7 @@ class InvoiceRenderView(LoginRequiredMixin, View):
|
||||
return response
|
||||
|
||||
|
||||
class RemittanceCreateView(LoginRequiredMixin, CreateView):
|
||||
class RemittanceCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
|
||||
"""
|
||||
Create Remittance
|
||||
"""
|
||||
@ -218,12 +201,14 @@ class RemittanceCreateView(LoginRequiredMixin, CreateView):
|
||||
return reverse_lazy('treasury:remittance_list')
|
||||
|
||||
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())
|
||||
ctx["special_transactions"] = SpecialTransactionTable(data=SpecialTransaction.objects.none())
|
||||
context["table"] = RemittanceTable(data=Remittance.objects
|
||||
.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):
|
||||
@ -233,24 +218,30 @@ class RemittanceListView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "treasury/remittance_list.html"
|
||||
|
||||
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())
|
||||
ctx["closed_remittances"] = RemittanceTable(data=Remittance.objects.filter(closed=True).reverse().all())
|
||||
context["opened_remittances"] = RemittanceTable(
|
||||
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)),
|
||||
specialtransactionproxy__remittance=None).all(),
|
||||
specialtransactionproxy__remittance=None).filter(
|
||||
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all(),
|
||||
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)),
|
||||
specialtransactionproxy__remittance__closed=False).all(),
|
||||
specialtransactionproxy__remittance__closed=False).filter(
|
||||
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all(),
|
||||
exclude=('remittance_add', ))
|
||||
|
||||
return ctx
|
||||
return context
|
||||
|
||||
|
||||
class RemittanceUpdateView(LoginRequiredMixin, UpdateView):
|
||||
class RemittanceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
Update Remittance
|
||||
"""
|
||||
@ -261,18 +252,20 @@ class RemittanceUpdateView(LoginRequiredMixin, UpdateView):
|
||||
return reverse_lazy('treasury:remittance_list')
|
||||
|
||||
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())
|
||||
data = SpecialTransaction.objects.filter(specialtransactionproxy__remittance=self.object).all()
|
||||
ctx["special_transactions"] = SpecialTransactionTable(
|
||||
context["table"] = RemittanceTable(data=Remittance.objects.filter(
|
||||
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all())
|
||||
data = SpecialTransaction.objects.filter(specialtransactionproxy__remittance=self.object).filter(
|
||||
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all()
|
||||
context["special_transactions"] = SpecialTransactionTable(
|
||||
data=data,
|
||||
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
|
||||
"""
|
||||
@ -284,9 +277,9 @@ class LinkTransactionToRemittanceView(LoginRequiredMixin, UpdateView):
|
||||
return reverse_lazy('treasury:remittance_list')
|
||||
|
||||
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["first_name"].initial = self.object.transaction.first_name
|
||||
form.fields["bank"].initial = self.object.transaction.bank
|
||||
@ -294,7 +287,7 @@ class LinkTransactionToRemittanceView(LoginRequiredMixin, UpdateView):
|
||||
form.fields["remittance"].queryset = form.fields["remittance"] \
|
||||
.queryset.filter(remittance_type__note=self.object.transaction.source)
|
||||
|
||||
return ctx
|
||||
return context
|
||||
|
||||
|
||||
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.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.forms',
|
||||
# API
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
# Autocomplete
|
||||
'dal',
|
||||
'dal_select2',
|
||||
|
||||
# Note apps
|
||||
'api',
|
||||
'activity',
|
||||
'logs',
|
||||
'member',
|
||||
'note',
|
||||
'treasury',
|
||||
'permission',
|
||||
'api',
|
||||
'logs',
|
||||
'registration',
|
||||
'treasury',
|
||||
]
|
||||
LOGIN_REDIRECT_URL = '/note/transfer/'
|
||||
|
||||
@ -100,6 +99,8 @@ TEMPLATES = [
|
||||
},
|
||||
]
|
||||
|
||||
FORM_RENDERER = 'django.forms.renderers.TemplatesSetting'
|
||||
|
||||
WSGI_APPLICATION = 'note_kfet.wsgi.application'
|
||||
|
||||
# Password validation
|
||||
|
@ -15,13 +15,15 @@ urlpatterns = [
|
||||
|
||||
# Include project routers
|
||||
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')),
|
||||
|
||||
# Include Django Contrib and Core routers
|
||||
path('i18n/', include('django.conf.urls.i18n')),
|
||||
path('admin/doc/', include('django.contrib.admindocs.urls')),
|
||||
path('admin/', admin.site.urls),
|
||||
path('accounts/', include('member.urls')),
|
||||
path('accounts/login/', CustomLoginView.as_view()),
|
||||
path('accounts/', include('django.contrib.auth.urls')),
|
||||
path('api/', include('api.urls')),
|
||||
@ -36,14 +38,7 @@ if "cas_server" in settings.INSTALLED_APPS:
|
||||
# Include CAS Server routers
|
||||
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:
|
||||
import debug_toolbar
|
||||
urlpatterns = [
|
||||
|
@ -3,7 +3,6 @@ chardet==3.0.4
|
||||
defusedxml==0.6.0
|
||||
Django~=2.2
|
||||
django-allauth==0.39.1
|
||||
django-autocomplete-light==3.5.1
|
||||
django-crispy-forms==1.7.2
|
||||
django-extensions==2.1.9
|
||||
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.
|
||||
* @param msg The message to display
|
||||
* @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 html = msgDiv.html();
|
||||
let id = Math.floor(10000 * Math.random() + 1);
|
||||
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";
|
||||
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
|
||||
*/
|
||||
@ -85,7 +122,7 @@ function displayNote(note, alias, user_note_field=null, profile_pic_field=null)
|
||||
if (alias !== note.name)
|
||||
alias += " (aka. " + note.name + ")";
|
||||
if (user_note_field !== null)
|
||||
|
||||
|
||||
$("#" + user_note_field).addClass(displayStyle(note.balance));
|
||||
$("#" + user_note_field).text(alias + (note.balance == null ? "" : (":\n" + pretty_money(note.balance))));
|
||||
if (profile_pic_field != null){
|
||||
@ -202,7 +239,7 @@ function autoCompleteNote(field_id, alias_matched_id, note_list_id, notes, notes
|
||||
notes.length = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
$.getJSON("/api/note/consumer/?format=json&alias="
|
||||
+ pattern
|
||||
+ "&search=user|club&ordering=normalized_name",
|
||||
@ -277,9 +314,9 @@ function autoCompleteNote(field_id, alias_matched_id, note_list_id, notes, notes
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
});// end getJSON alias
|
||||
});
|
||||
});
|
||||
}// end function autocomplete
|
||||
|
||||
// When a validate button is clicked, we switch the validation status
|
||||
@ -296,8 +333,9 @@ function de_validate(id, validated) {
|
||||
"X-CSRFTOKEN": CSRF_TOKEN
|
||||
},
|
||||
data: {
|
||||
"resourcetype": "RecurrentTransaction",
|
||||
valid: !validated
|
||||
resourcetype: "RecurrentTransaction",
|
||||
valid: !validated,
|
||||
invalidity_reason: invalidity_reason,
|
||||
},
|
||||
success: function () {
|
||||
// Refresh jQuery objects
|
||||
|
@ -61,16 +61,24 @@ $(document).ready(function() {
|
||||
|
||||
|
||||
// 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_credit").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_credit']").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')) {
|
||||
dests_notes_display.forEach(function (dest) {
|
||||
$.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