mirror of
				https://gitlab.crans.org/bde/nk20
				synced 2025-10-31 07:49:57 +01:00 
			
		
		
		
	Merge branch 'master' into tranfer_front
# Conflicts: # static/js/base.js
This commit is contained in:
		
							
								
								
									
										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
		Reference in New Issue
	
	Block a user