diff --git a/api/permissions.py b/api/permissions.py index 3054acd2..8753202e 100644 --- a/api/permissions.py +++ b/api/permissions.py @@ -239,7 +239,9 @@ class AutodetectACLPermission(permissions.BasePermission): if getattr(view, "_ignore_model_permissions", False): return True - if not getattr(view, "queryset", getattr(view, "get_queryset", None)): + # Bypass permission verifications if it is a functional view + # (permissions are handled by ACL) + if not hasattr(view, "queryset") and not hasattr(view, "get_queryset"): return True if not request.user or not request.user.is_authenticated: diff --git a/cotisations/forms.py b/cotisations/forms.py index 0f135963..f9e44686 100644 --- a/cotisations/forms.py +++ b/cotisations/forms.py @@ -46,6 +46,7 @@ from django.shortcuts import get_object_or_404 from re2o.field_permissions import FieldPermissionFormMixin from re2o.mixins import FormRevMixin +from re2o.widgets import AutocompleteModelWidget from .models import ( Article, Paiement, @@ -79,6 +80,10 @@ class FactureForm(FieldPermissionFormMixin, FormRevMixin, ModelForm): class Meta: model = Facture fields = "__all__" + widgets = { + "user": AutocompleteModelWidget(url="/users/user-autocomplete"), + "banque": AutocompleteModelWidget(url="/cotisations/banque-autocomplete"), + } def clean(self): cleaned_data = super(FactureForm, self).clean() diff --git a/cotisations/migrations/0051_auto_20201228_1636.py b/cotisations/migrations/0051_auto_20201228_1636.py new file mode 100644 index 00000000..572c9634 --- /dev/null +++ b/cotisations/migrations/0051_auto_20201228_1636.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2020-12-28 15:36 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("cotisations", "0050_auto_20201102_2342"), + ] + + operations = [ + migrations.AlterField( + model_name="article", + name="duration_connection", + field=models.PositiveIntegerField( + verbose_name="duration of the connection (in months)" + ), + ), + migrations.AlterField( + model_name="article", + name="duration_days_connection", + field=models.PositiveIntegerField( + verbose_name="duration of the connection (in days, will be added to duration in months)" + ), + ), + migrations.AlterField( + model_name="article", + name="duration_days_membership", + field=models.PositiveIntegerField( + verbose_name="duration of the membership (in days, will be added to duration in months)" + ), + ), + migrations.AlterField( + model_name="article", + name="duration_membership", + field=models.PositiveIntegerField( + verbose_name="duration of the membership (in months)" + ), + ), + migrations.AlterField( + model_name="vente", + name="duration_days_connection", + field=models.PositiveIntegerField( + default=0, + verbose_name="duration of the connection (in days, will be added to duration in months)", + ), + ), + migrations.AlterField( + model_name="vente", + name="duration_days_membership", + field=models.PositiveIntegerField( + default=0, + verbose_name="duration of the membership (in days, will be added to duration in months)", + ), + ), + ] diff --git a/cotisations/models.py b/cotisations/models.py index b3480561..5dc18e4f 100644 --- a/cotisations/models.py +++ b/cotisations/models.py @@ -98,7 +98,7 @@ class BaseInvoice(RevMixin, AclMixin, FieldPermissionModelMixin, models.Model): def name_detailed(self): """ - Return: + Return: - a list of strings with the name of all article in the invoice and their quantity. """ @@ -248,8 +248,8 @@ class Facture(BaseInvoice): @staticmethod def can_change_control(user_request, *_args, **_kwargs): - """ Returns True if the user can change the 'controlled' status of - this invoice """ + """Returns True if the user can change the 'controlled' status of + this invoice""" can = user_request.has_perm("cotisations.change_facture_control") return ( can, @@ -293,8 +293,7 @@ class Facture(BaseInvoice): """Returns every subscription associated with this invoice.""" return Cotisation.objects.filter( vente__in=self.vente_set.filter( - ~(Q(duration_membership=0)) |\ - ~(Q(duration_days_membership=0)) + ~(Q(duration_membership=0)) | ~(Q(duration_days_membership=0)) ) ) @@ -454,7 +453,7 @@ class Vente(RevMixin, AclMixin, models.Model): number = models.IntegerField( validators=[MinValueValidator(1)], verbose_name=_("amount") ) - # TODO : change this field for a ForeinKey to Article + # TODO : change this field for a ForeinKey to Article # Note: With a foreign key, modifing an Article modifis the Purchase, wich is bad. # To use a foreign key, you need to make Article read only name = models.CharField(max_length=255, verbose_name=_("article")) @@ -467,16 +466,18 @@ class Vente(RevMixin, AclMixin, models.Model): ) duration_days_connection = models.PositiveIntegerField( default=0, - validators=[MinValueValidator(0)], - verbose_name=_("duration of the connection (in days, will be added to duration in months)"), + verbose_name=_( + "duration of the connection (in days, will be added to duration in months)" + ), ) duration_membership = models.PositiveIntegerField( default=0, verbose_name=_("duration of the membership (in months)") ) duration_days_membership = models.PositiveIntegerField( default=0, - validators=[MinValueValidator(0)], - verbose_name=_("duration of the membership (in days, will be added to duration in months)"), + verbose_name=_( + "duration of the membership (in days, will be added to duration in months)" + ), ) class Meta: @@ -513,7 +514,7 @@ class Vente(RevMixin, AclMixin, models.Model): def create_cotis(self, date_start_con=False, date_start_memb=False): """ - Creates a cotisation without initializing the dates (start and end ar set to self.facture.facture.date) + Creates a cotisation without initializing the dates (start and end ar set to self.facture.facture.date) and without saving it. You should use Facture.reorder_purchases to set the right dates. """ try: @@ -631,12 +632,14 @@ class Vente(RevMixin, AclMixin, models.Model): return str(self.name) + " " + str(self.facture) def test_membership_or_connection(self): - """ Test if the purchase include membership or connecton - """ - return self.duration_membership or \ - self.duration_days_membership or \ - self.duration_connection or \ - self.duration_days_connection + """Test if the purchase include membership or connecton""" + return ( + self.duration_membership + or self.duration_days_membership + or self.duration_connection + or self.duration_days_connection + ) + # TODO : change vente to purchase @receiver(post_save, sender=Vente) @@ -704,20 +707,20 @@ class Article(RevMixin, AclMixin, models.Model): ) duration_membership = models.PositiveIntegerField( - validators=[MinValueValidator(0)], verbose_name=_("duration of the membership (in months)") ) duration_days_membership = models.PositiveIntegerField( - validators=[MinValueValidator(0)], - verbose_name=_("duration of the membership (in days, will be added to duration in months)"), + verbose_name=_( + "duration of the membership (in days, will be added to duration in months)" + ), ) duration_connection = models.PositiveIntegerField( - validators=[MinValueValidator(0)], verbose_name=_("duration of the connection (in months)") ) duration_days_connection = models.PositiveIntegerField( - validators=[MinValueValidator(0)], - verbose_name=_("duration of the connection (in days, will be added to duration in months)"), + verbose_name=_( + "duration of the connection (in days, will be added to duration in months)" + ), ) need_membership = models.BooleanField( @@ -793,8 +796,8 @@ class Article(RevMixin, AclMixin, models.Model): if target_user is not None and not target_user.is_adherent(): objects_pool = objects_pool.filter( Q(duration_membership__gt=0) - |Q(duration_days_membership__gt=0) - |Q(need_membership=False) + | Q(duration_days_membership__gt=0) + | Q(need_membership=False) ) if user.has_perm("cotisations.buy_every_article"): return objects_pool @@ -884,7 +887,9 @@ class Paiement(RevMixin, AclMixin, models.Model): # In case a cotisation was bought, inform the user, the # cotisation time has been extended too - if any(sell.test_membership_or_connection() for sell in invoice.vente_set.all()): + if any( + sell.test_membership_or_connection() for sell in invoice.vente_set.all() + ): messages.success( request, _( @@ -956,9 +961,13 @@ class Cotisation(RevMixin, AclMixin, models.Model): vente = models.OneToOneField( "Vente", on_delete=models.CASCADE, null=True, verbose_name=_("purchase") ) - date_start_con = models.DateTimeField(verbose_name=_("start date for the connection")) + date_start_con = models.DateTimeField( + verbose_name=_("start date for the connection") + ) date_end_con = models.DateTimeField(verbose_name=_("end date for the connection")) - date_start_memb = models.DateTimeField(verbose_name=_("start date for the membership")) + date_start_memb = models.DateTimeField( + verbose_name=_("start date for the membership") + ) date_end_memb = models.DateTimeField(verbose_name=_("end date for the membership")) class Meta: diff --git a/cotisations/templates/cotisations/edit_facture.html b/cotisations/templates/cotisations/edit_facture.html index ca55cb66..b2f58df9 100644 --- a/cotisations/templates/cotisations/edit_facture.html +++ b/cotisations/templates/cotisations/edit_facture.html @@ -25,13 +25,13 @@ with this program; if not, write to the Free Software Foundation, Inc., {% load bootstrap3 %} {% load staticfiles%} -{% load massive_bootstrap_form %} {% load i18n %} {% block title %}{% trans "Creation and editing of invoices" %}{% endblock %} {% block content %} {% bootstrap_form_errors factureform %} +{{ factureform.media }}
{% csrf_token %} @@ -40,7 +40,7 @@ with this program; if not, write to the Free Software Foundation, Inc., {% else %}

{% trans "Edit invoice" %}

{% endif %} - {% massive_bootstrap_form factureform 'user' %} + {% bootstrap_form factureform %} {{ venteform.management_form }}

{% trans "Articles" %}

diff --git a/cotisations/urls.py b/cotisations/urls.py index 6baf74c7..c78effa6 100644 --- a/cotisations/urls.py +++ b/cotisations/urls.py @@ -27,7 +27,7 @@ from __future__ import unicode_literals from django.conf.urls import url -from . import views +from . import views, views_autocomplete from . import payment_methods urlpatterns = [ @@ -104,4 +104,6 @@ urlpatterns = [ url(r"^index_paiement/$", views.index_paiement, name="index-paiement"), url(r"^control/$", views.control, name="control"), url(r"^$", views.index, name="index"), + ### Autocomplete Views + url(r'^banque-autocomplete/$', views_autocomplete.BanqueAutocomplete.as_view(), name='banque-autocomplete',), ] + payment_methods.urls.urlpatterns diff --git a/cotisations/views_autocomplete.py b/cotisations/views_autocomplete.py new file mode 100644 index 00000000..1e634813 --- /dev/null +++ b/cotisations/views_autocomplete.py @@ -0,0 +1,50 @@ +# -*- mode: python; coding: utf-8 -*- +# Re2o est un logiciel d'administration développé initiallement au Rézo Metz. Il +# se veut agnostique au réseau considéré, de manière à être installable en +# quelques clics. +# +# Copyright © 2017-2020 Gabriel Détraz +# Copyright © 2017-2020 Jean-Romain Garnier +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +# App de gestion des users pour re2o +# Lara Kermarec, Gabriel Détraz, Lemesle Augustin +# Gplv2 +""" +Django views autocomplete view + +Here are defined the autocomplete class based view. + +""" +from __future__ import unicode_literals + +from django.db.models import Q, Value, CharField + +from .models import ( + Banque +) + +from re2o.views import AutocompleteViewMixin + +from re2o.acl import ( + can_view_all, +) + + +class BanqueAutocomplete(AutocompleteViewMixin): + obj_type = Banque + + diff --git a/logs/forms.py b/logs/forms.py index b8c8b010..0a2ca09c 100644 --- a/logs/forms.py +++ b/logs/forms.py @@ -25,6 +25,7 @@ from django import forms from django.forms import Form from django.utils.translation import ugettext_lazy as _ from re2o.base import get_input_formats_help_text +from re2o.widgets import AutocompleteModelWidget import inspect @@ -46,10 +47,7 @@ CHOICES_ACTION_TYPE = ( ("all", _("All")), ) -CHOICES_TYPE = ( - ("ip", _("IPv4")), - ("mac", _("MAC address")), -) +CHOICES_TYPE = (("ip", _("IPv4")), ("mac", _("MAC address"))) def all_classes(module): @@ -87,14 +85,11 @@ def classes_for_action_type(action_type): users.models.User.__name__, users.models.Adherent.__name__, users.models.Club.__name__, - users.models.EMailAddress.__name__ + users.models.EMailAddress.__name__, ] if action_type == "machines": - return [ - machines.models.Machine.__name__, - machines.models.Interface.__name__ - ] + return [machines.models.Machine.__name__, machines.models.Interface.__name__] if action_type == "subscriptions": return all_classes(cotisations.models) @@ -114,40 +109,39 @@ def classes_for_action_type(action_type): class ActionsSearchForm(Form): """Form used to do an advanced search through the logs.""" - u = forms.ModelChoiceField( + + user = forms.ModelChoiceField( label=_("Performed by"), queryset=users.models.User.objects.all(), required=False, + widget=AutocompleteModelWidget(url="/users/user-autocomplete"), ) - t = forms.MultipleChoiceField( + action_type = forms.MultipleChoiceField( label=_("Action type"), required=False, widget=forms.CheckboxSelectMultiple, choices=CHOICES_ACTION_TYPE, initial=[i[0] for i in CHOICES_ACTION_TYPE], ) - s = forms.DateField(required=False, label=_("Start date")) - e = forms.DateField(required=False, label=_("End date")) + start_date = forms.DateField(required=False, label=_("Start date")) + end_date = forms.DateField(required=False, label=_("End date")) def __init__(self, *args, **kwargs): super(ActionsSearchForm, self).__init__(*args, **kwargs) - self.fields["s"].help_text = get_input_formats_help_text( - self.fields["s"].input_formats + self.fields["start_date"].help_text = get_input_formats_help_text( + self.fields["start_date"].input_formats ) - self.fields["e"].help_text = get_input_formats_help_text( - self.fields["e"].input_formats + self.fields["end_date"].help_text = get_input_formats_help_text( + self.fields["end_date"].input_formats ) class MachineHistorySearchForm(Form): """Form used to do a search through the machine histories.""" - q = forms.CharField( - label=_("Search"), - max_length=100, - ) + + q = forms.CharField(label=_("Search"), max_length=100) t = forms.CharField( - label=_("Search type"), - widget=forms.Select(choices=CHOICES_TYPE) + label=_("Search type"), widget=forms.Select(choices=CHOICES_TYPE) ) s = forms.DateField(required=False, label=_("Start date")) e = forms.DateField(required=False, label=_("End date")) diff --git a/logs/models.py b/logs/models.py index 8790f46e..aa5d7822 100644 --- a/logs/models.py +++ b/logs/models.py @@ -600,10 +600,10 @@ class ActionsSearch: Returns: The QuerySet of Revision objects corresponding to the search. """ - user = params.get("u", None) - start = params.get("s", None) - end = params.get("e", None) - action_types = params.get("t", None) + user = params.get("user", None) + start = params.get("start_date", None) + end = params.get("end_date", None) + action_types = params.get("action_type", None) query = Q() diff --git a/logs/templates/logs/search_stats_logs.html b/logs/templates/logs/search_stats_logs.html index b124139e..88c2babd 100644 --- a/logs/templates/logs/search_stats_logs.html +++ b/logs/templates/logs/search_stats_logs.html @@ -22,7 +22,6 @@ with this program; if not, write to the Free Software Foundation, Inc., {% endcomment %} {% load bootstrap3 %} -{% load massive_bootstrap_form %} {% load i18n %} {% block title %}{% trans "Search events" %}{% endblock %} @@ -32,10 +31,11 @@ with this program; if not, write to the Free Software Foundation, Inc.,

{% trans "Search events" %}

- {% massive_bootstrap_form actions_form 'u' %} + {% bootstrap_form actions_form %} {% trans "Search" as tr_search %} {% bootstrap_button tr_search button_type="submit" icon="search" %} +{{ actions_form.media }}


diff --git a/machines/forms.py b/machines/forms.py index 03f1ecc7..ed5975e3 100644 --- a/machines/forms.py +++ b/machines/forms.py @@ -41,6 +41,10 @@ from django.utils.translation import ugettext_lazy as _ from re2o.field_permissions import FieldPermissionFormMixin from re2o.mixins import FormRevMixin +from re2o.widgets import ( + AutocompleteModelWidget, + AutocompleteMultipleModelWidget, +) from .models import ( Domain, Machine, @@ -71,6 +75,7 @@ class EditMachineForm(FormRevMixin, FieldPermissionFormMixin, ModelForm): class Meta: model = Machine fields = "__all__" + widgets = {"user": AutocompleteModelWidget(url="/users/user-autocomplete")} def __init__(self, *args, **kwargs): prefix = kwargs.pop("prefix", self.Meta.model.__name__) @@ -91,6 +96,19 @@ class EditInterfaceForm(FormRevMixin, FieldPermissionFormMixin, ModelForm): class Meta: model = Interface fields = ["machine", "machine_type", "ipv4", "mac_address", "details"] + widgets = { + "machine": AutocompleteModelWidget(url="/machines/machine-autocomplete"), + "machine_type": AutocompleteModelWidget( + url="/machines/machinetype-autocomplete" + ), + "ipv4": AutocompleteModelWidget( + url="/machines/iplist-autocomplete", + forward=["machine_type"], + attrs={ + "data-placeholder": "Automatic assigment. Type to choose specific ip." + }, + ), + } def __init__(self, *args, **kwargs): prefix = kwargs.pop("prefix", self.Meta.model.__name__) @@ -139,6 +157,9 @@ class AliasForm(FormRevMixin, FieldPermissionFormMixin, ModelForm): class Meta: model = Domain fields = ["name", "extension", "ttl"] + widgets = { + "extension": AutocompleteModelWidget(url="/machines/extension-autocomplete") + } def __init__(self, *args, **kwargs): prefix = kwargs.pop("prefix", self.Meta.model.__name__) @@ -188,6 +209,9 @@ class MachineTypeForm(FormRevMixin, ModelForm): class Meta: model = MachineType fields = ["name", "ip_type"] + widgets = { + "ip_type": AutocompleteModelWidget(url="/machines/iptype-autocomplete") + } def __init__(self, *args, **kwargs): prefix = kwargs.pop("prefix", self.Meta.model.__name__) @@ -222,6 +246,13 @@ class IpTypeForm(FormRevMixin, ModelForm): class Meta: model = IpType fields = "__all__" + widgets = { + "vlan": AutocompleteModelWidget(url="/machines/vlan-autocomplete"), + "extension": AutocompleteModelWidget(url="/machines/extension-autocomplete"), + "ouverture_ports": AutocompleteModelWidget( + url="/machines/ouvertureportlist-autocomplete" + ), + } def __init__(self, *args, **kwargs): prefix = kwargs.pop("prefix", self.Meta.model.__name__) @@ -351,6 +382,10 @@ class MxForm(FormRevMixin, ModelForm): class Meta: model = Mx fields = ["zone", "priority", "name", "ttl"] + widgets = { + "zone": AutocompleteModelWidget(url="/machines/extension-autocomplete"), + "name": AutocompleteModelWidget(url="/machines/domain-autocomplete"), + } def __init__(self, *args, **kwargs): prefix = kwargs.pop("prefix", self.Meta.model.__name__) @@ -386,6 +421,10 @@ class NsForm(FormRevMixin, ModelForm): class Meta: model = Ns fields = ["zone", "ns", "ttl"] + widgets = { + "zone": AutocompleteModelWidget(url="/machines/extension-autocomplete"), + "ns": AutocompleteModelWidget(url="/machines/domain-autocomplete"), + } def __init__(self, *args, **kwargs): prefix = kwargs.pop("prefix", self.Meta.model.__name__) @@ -419,6 +458,9 @@ class TxtForm(FormRevMixin, ModelForm): class Meta: model = Txt fields = "__all__" + widgets = { + "zone": AutocompleteModelWidget(url="/machines/extension-autocomplete") + } def __init__(self, *args, **kwargs): prefix = kwargs.pop("prefix", self.Meta.model.__name__) @@ -449,6 +491,9 @@ class DNameForm(FormRevMixin, ModelForm): class Meta: model = DName fields = "__all__" + widgets = { + "zone": AutocompleteModelWidget(url="/machines/extension-autocomplete") + } def __init__(self, *args, **kwargs): prefix = kwargs.pop("prefix", self.Meta.model.__name__) @@ -479,6 +524,10 @@ class SrvForm(FormRevMixin, ModelForm): class Meta: model = Srv fields = "__all__" + widgets = { + "extension": AutocompleteModelWidget(url="/machines/extension-autocomplete"), + "target": AutocompleteModelWidget(url="/machines/domain-autocomplete"), + } def __init__(self, *args, **kwargs): prefix = kwargs.pop("prefix", self.Meta.model.__name__) @@ -509,6 +558,14 @@ class NasForm(FormRevMixin, ModelForm): class Meta: model = Nas fields = "__all__" + widgets = { + "nas_type": AutocompleteModelWidget( + url="/machines/machinetype-autocomplete" + ), + "machine_type": AutocompleteModelWidget( + url="/machines/machinetype-autocomplete" + ), + } def __init__(self, *args, **kwargs): prefix = kwargs.pop("prefix", self.Meta.model.__name__) @@ -539,6 +596,11 @@ class RoleForm(FormRevMixin, ModelForm): class Meta: model = Role fields = "__all__" + widgets = { + "servers": AutocompleteMultipleModelWidget( + url="/machines/interface-autocomplete" + ) + } def __init__(self, *args, **kwargs): prefix = kwargs.pop("prefix", self.Meta.model.__name__) @@ -572,6 +634,11 @@ class ServiceForm(FormRevMixin, ModelForm): class Meta: model = Service fields = "__all__" + widgets = { + "servers": AutocompleteMultipleModelWidget( + url="/machines/interface-autocomplete" + ) + } def __init__(self, *args, **kwargs): prefix = kwargs.pop("prefix", self.Meta.model.__name__) @@ -656,6 +723,11 @@ class EditOuverturePortConfigForm(FormRevMixin, ModelForm): class Meta: model = Interface fields = ["port_lists"] + widgets = { + "port_lists": AutocompleteMultipleModelWidget( + url="/machines/ouvertureportlist-autocomplete" + ) + } def __init__(self, *args, **kwargs): prefix = kwargs.pop("prefix", self.Meta.model.__name__) diff --git a/machines/models.py b/machines/models.py index 890b414d..9fd524be 100644 --- a/machines/models.py +++ b/machines/models.py @@ -378,6 +378,34 @@ class MachineType(RevMixin, AclMixin, models.Model): ) return True, None, None + @classmethod + def can_list(cls, user_request, *_args, **_kwargs): + """All users can list unprivileged machinetypes + Only members of privileged groups can list all. + + :param user_request: The user who wants to view the list. + :return: True if the user can view the list and an explanation + message. + + """ + can, _message, _group = cls.can_use_all(user_request) + if can: + return ( + True, + None, + None, + cls.objects.all() + ) + else: + return ( + True, + _("You don't have the right to use all machine types."), + ("machines.use_all_machinetype",), + cls.objects.filter( + ip_type__in=IpType.objects.filter(need_infra=False) + ), + ) + def __str__(self): return self.name @@ -953,6 +981,32 @@ class Extension(RevMixin, AclMixin, models.Model): ("machines.use_all_extension",), ) + @classmethod + def can_list(cls, user_request, *_args, **_kwargs): + """All users can list unprivileged extensions + Only members of privileged groups can list all. + + :param user_request: The user who wants to view the list. + :return: True if the user can view the list and an explanation + message. + + """ + can, _message, _group = cls.can_use_all(user_request) + if can: + return ( + True, + None, + None, + cls.objects.all() + ) + else: + return ( + True, + _("You don't have the right to list all extensions."), + ("machines.use_all_extension",), + cls.objects.filter(need_infra=False), + ) + def __str__(self): return self.name @@ -2130,6 +2184,34 @@ class IpList(RevMixin, AclMixin, models.Model): self.clean() super(IpList, self).save(*args, **kwargs) + @classmethod + def can_list(cls, user_request, *_args, **_kwargs): + """Only privilged users can list all ipv4. + Others can list Ipv4 related with unprivileged type. + + :param user_request: The user who wants to view the list. + :return: True if the user can view the list and an explanation + message. + + """ + can, _message, _group = IpType.can_use_all(user_request) + if can: + return ( + True, + None, + None, + cls.objects.all() + ) + else: + return ( + True, + _("You don't have the right to use all machine types."), + ("machines.use_all_machinetype",), + cls.objects.filter( + ip_type__in=IpType.objects.filter(need_infra=False) + ), + ) + def __str__(self): return self.ipv4 diff --git a/machines/templates/machines/machine.html b/machines/templates/machines/machine.html index f74730b2..bc1cdbeb 100644 --- a/machines/templates/machines/machine.html +++ b/machines/templates/machines/machine.html @@ -25,7 +25,6 @@ with this program; if not, write to the Free Software Foundation, Inc., {% endcomment %} {% load bootstrap3 %} -{% load massive_bootstrap_form %} {% load i18n %} {% block title %}{% trans "Machines" %}{% endblock %} @@ -33,54 +32,67 @@ with this program; if not, write to the Free Software Foundation, Inc., {% block content %} {% if machineform %} {% bootstrap_form_errors machineform %} + {{ machineform.media }} {% endif %} {% if interfaceform %} {% bootstrap_form_errors interfaceform %} + {{ interfaceform.media }} {% endif %} {% if domainform %} {% bootstrap_form_errors domainform %} {% endif %} {% if iptypeform %} {% bootstrap_form_errors iptypeform %} + {{ iptypeform.media }} {% endif %} {% if machinetypeform %} {% bootstrap_form_errors machinetypeform %} + {{ machinetypeform.media }} {% endif %} {% if extensionform %} {% bootstrap_form_errors extensionform %} {% endif %} {% if mxform %} {% bootstrap_form_errors mxform %} + {{ mxform.media }} {% endif %} {% if nsform %} {% bootstrap_form_errors nsform %} + {{ nsform.media }} {% endif %} {% if txtform %} {% bootstrap_form_errors txtform %} + {{ txtform.media }} {% endif %} {% if dnameform %} {% bootstrap_form_errors dnameform %} + {{ dnameform.media }} {% endif %} {% if srvform %} {% bootstrap_form_errors srvform %} + {{ srvform.media }} {% endif %} {% if aliasform %} {% bootstrap_form_errors aliasform %} + {{ aliasform.media }} {% endif %} {% if serviceform %} {% bootstrap_form_errors serviceform %} + {{ serviceform.media }} {% endif %} {% if sshfpform %} {% bootstrap_form_errors sshfpform %} {% endif %} {% if roleform %} {% bootstrap_form_errors roleform %} + {{ roleform.media }} {% endif %} {% if vlanform %} {% bootstrap_form_errors vlanform %} {% endif %} {% if nasform %} {% bootstrap_form_errors nasform %} + {{ nasform.media }} {% endif %} {% if ipv6form %} {% bootstrap_form_errors ipv6form %} @@ -90,15 +102,11 @@ with this program; if not, write to the Free Software Foundation, Inc., {% csrf_token %} {% if machineform %}

{% trans "Machine" %}

- {% massive_bootstrap_form machineform 'user' %} + {% bootstrap_form machineform %} {% endif %} {% if interfaceform %}

{% trans "Interface" %}

- {% if i_mbf_param %} - {% massive_bootstrap_form interfaceform 'ipv4,machine,port_lists' mbf_param=i_mbf_param %} - {% else %} - {% massive_bootstrap_form interfaceform 'ipv4,machine,port_lists' %} - {% endif %} + {% bootstrap_form interfaceform %} {% endif %} {% if domainform %}

{% trans "Domain" %}

@@ -114,7 +122,7 @@ with this program; if not, write to the Free Software Foundation, Inc., {% endif %} {% if extensionform %}

{% trans "Extension" %}

- {% massive_bootstrap_form extensionform 'origin' %} + {% bootstrap_form extensionform %} {% endif %} {% if soaform %}

{% trans "SOA record" %}

@@ -122,11 +130,11 @@ with this program; if not, write to the Free Software Foundation, Inc., {% endif %} {% if mxform %}

{% trans "MX record" %}

- {% massive_bootstrap_form mxform 'name' %} + {% bootstrap_form mxform %} {% endif %} {% if nsform %}

{% trans "NS record" %}

- {% massive_bootstrap_form nsform 'ns' %} + {% bootstrap_form nsform %} {% endif %} {% if txtform %}

{% trans "TXT record" %}

@@ -138,7 +146,7 @@ with this program; if not, write to the Free Software Foundation, Inc., {% endif %} {% if srvform %}

{% trans "SRV record" %}

- {% massive_bootstrap_form srvform 'target' %} + {% bootstrap_form srvform %} {% endif %} {% if sshfpform %}

{% trans "SSHFP record" %}

@@ -146,15 +154,15 @@ with this program; if not, write to the Free Software Foundation, Inc., {% endif %} {% if aliasform %}

{% trans "Alias" %}

- {% massive_bootstrap_form aliasform 'extension' %} + {% bootstrap_form aliasform %} {% endif %} {% if serviceform %}

{% trans "Service" %}

- {% massive_bootstrap_form serviceform 'servers' %} + {% bootstrap_form serviceform %} {% endif %} {% if roleform %}

Role

- {% massive_bootstrap_form roleform 'servers' %} + {% bootstrap_form roleform %} {% endif %} {% if vlanform %}

{% trans "VLAN" %}

diff --git a/machines/urls.py b/machines/urls.py index a8e1dbea..98a6695f 100644 --- a/machines/urls.py +++ b/machines/urls.py @@ -29,6 +29,7 @@ from __future__ import unicode_literals from django.conf.urls import url from . import views +from . import views_autocomplete urlpatterns = [ url(r"^new_machine/(?P[0-9]+)$", views.new_machine, name="new-machine"), @@ -153,4 +154,14 @@ urlpatterns = [ views.configure_ports, name="port-config", ), + ### Autocomplete Views + url(r'^vlan-autocomplete/$', views_autocomplete.VlanAutocomplete.as_view(), name='vlan-autocomplete',), + url(r'^interface-autocomplete/$', views_autocomplete.InterfaceAutocomplete.as_view(), name='interface-autocomplete',), + url(r'^machine-autocomplete/$', views_autocomplete.MachineAutocomplete.as_view(), name='machine-autocomplete',), + url(r'^machinetype-autocomplete/$', views_autocomplete.MachineTypeAutocomplete.as_view(), name='machinetype-autocomplete',), + url(r'^iptype-autocomplete/$', views_autocomplete.IpTypeAutocomplete.as_view(), name='iptype-autocomplete',), + url(r'^extension-autocomplete/$', views_autocomplete.ExtensionAutocomplete.as_view(), name='extension-autocomplete',), + url(r'^domain-autocomplete/$', views_autocomplete.DomainAutocomplete.as_view(), name='domain-autocomplete',), + url(r'^ouvertureportlist-autocomplete/$', views_autocomplete.OuverturePortListAutocomplete.as_view(), name='ouvertureportlist-autocomplete',), + url(r'^iplist-autocomplete/$', views_autocomplete.IpListAutocomplete.as_view(), name='iplist-autocomplete',), ] diff --git a/machines/views.py b/machines/views.py index 8ec85583..04b8a3a8 100644 --- a/machines/views.py +++ b/machines/views.py @@ -124,91 +124,6 @@ from .models import ( ) - -def f_type_id(is_type_tt): - """ The id that will be used in HTML to store the value of the field - type. Depends on the fact that type is generate using typeahead or not - """ - return ( - "id_Interface-machine_type_hidden" - if is_type_tt - else "id_Interface-machine_type" - ) - - -def generate_ipv4_choices(form_obj): - """ Generate the parameter choices for the massive_bootstrap_form tag - """ - f_ipv4 = form_obj.fields["ipv4"] - used_mtype_id = [] - choices = '{"":[{key:"",value:"' + _("Select a machine type first.") + '"}' - mtype_id = -1 - - for ip in f_ipv4.queryset.annotate(mtype_id=F("ip_type__machinetype__id")).order_by( - "mtype_id", "id" - ): - if mtype_id != ip.mtype_id: - mtype_id = ip.mtype_id - used_mtype_id.append(mtype_id) - choices += '],"{t}":[{{key:"",value:"{v}"}},'.format( - t=mtype_id, v=f_ipv4.empty_label or '""' - ) - choices += '{{key:{k},value:"{v}"}},'.format(k=ip.id, v=ip.ipv4) - - for t in form_obj.fields["machine_type"].queryset.exclude(id__in=used_mtype_id): - choices += '], "' + str(t.id) + '": [' - choices += '{key: "", value: "' + str(f_ipv4.empty_label) + '"},' - choices += "]}" - return choices - - -def generate_ipv4_engine(is_type_tt): - """ Generate the parameter engine for the massive_bootstrap_form tag - """ - return ( - "new Bloodhound( {{" - 'datumTokenizer: Bloodhound.tokenizers.obj.whitespace( "value" ),' - "queryTokenizer: Bloodhound.tokenizers.whitespace," - 'local: choices_ipv4[ $( "#{machine_type_id}" ).val() ],' - "identify: function( obj ) {{ return obj.key; }}" - "}} )" - ).format(machine_type_id=f_type_id(is_type_tt)) - - -def generate_ipv4_match_func(is_type_tt): - """ Generate the parameter match_func for the massive_bootstrap_form tag - """ - return ( - "function(q, sync) {{" - 'if (q === "") {{' - 'var first = choices_ipv4[$("#{machine_type_id}").val()].slice(0, 5);' - "first = first.map( function (obj) {{ return obj.key; }} );" - "sync(engine_ipv4.get(first));" - "}} else {{" - "engine_ipv4.search(q, sync);" - "}}" - "}}" - ).format(machine_type_id=f_type_id(is_type_tt)) - - -def generate_ipv4_mbf_param(form_obj, is_type_tt): - """ Generate all the parameters to use with the massive_bootstrap_form - tag """ - i_choices = {"ipv4": generate_ipv4_choices(form_obj)} - i_engine = {"ipv4": generate_ipv4_engine(is_type_tt)} - i_match_func = {"ipv4": generate_ipv4_match_func(is_type_tt)} - i_update_on = {"ipv4": [f_type_id(is_type_tt)]} - i_gen_select = {"ipv4": False} - i_mbf_param = { - "choices": i_choices, - "engine": i_engine, - "match_func": i_match_func, - "update_on": i_update_on, - "gen_select": i_gen_select, - } - return i_mbf_param - - @login_required @can_create(Machine) @can_edit(User) @@ -235,13 +150,11 @@ def new_machine(request, user, **_kwargs): new_domain.save() messages.success(request, _("The machine was created.")) return redirect(reverse("users:profil", kwargs={"userid": str(user.id)})) - i_mbf_param = generate_ipv4_mbf_param(interface, False) return form( { "machineform": machine, "interfaceform": interface, "domainform": domain, - "i_mbf_param": i_mbf_param, "action_name": _("Add"), }, "machines/machine.html", @@ -281,13 +194,11 @@ def edit_interface(request, interface_instance, **_kwargs): kwargs={"userid": str(interface_instance.machine.user.id)}, ) ) - i_mbf_param = generate_ipv4_mbf_param(interface_form, False) return form( { "machineform": machine_form, "interfaceform": interface_form, "domainform": domain_form, - "i_mbf_param": i_mbf_param, "action_name": _("Edit"), }, "machines/machine.html", @@ -332,12 +243,10 @@ def new_interface(request, machine, **_kwargs): return redirect( reverse("users:profil", kwargs={"userid": str(machine.user.id)}) ) - i_mbf_param = generate_ipv4_mbf_param(interface_form, False) return form( { "interfaceform": interface_form, "domainform": domain_form, - "i_mbf_param": i_mbf_param, "action_name": _("Add"), }, "machines/machine.html", diff --git a/machines/views_autocomplete.py b/machines/views_autocomplete.py new file mode 100644 index 00000000..94b55a59 --- /dev/null +++ b/machines/views_autocomplete.py @@ -0,0 +1,105 @@ +# -*- mode: python; coding: utf-8 -*- +# Re2o est un logiciel d'administration développé initiallement au Rézo Metz. Il +# se veut agnostique au réseau considéré, de manière à être installable en +# quelques clics. +# +# Copyright © 2017-2020 Gabriel Détraz +# Copyright © 2017-2020 Jean-Romain Garnier +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +# App de gestion des users pour re2o +# Lara Kermarec, Gabriel Détraz, Lemesle Augustin +# Gplv2 +""" +Django views autocomplete view + +Here are defined the autocomplete class based view. + +""" +from __future__ import unicode_literals + +from django.db.models import Q, Value, CharField +from django.db.models.functions import Concat + +from .models import ( + Interface, + Machine, + Vlan, + MachineType, + IpType, + Extension, + Domain, + OuverturePortList, + IpList, +) + +from re2o.views import AutocompleteViewMixin + + +class VlanAutocomplete(AutocompleteViewMixin): + obj_type = Vlan + + +class MachineAutocomplete(AutocompleteViewMixin): + obj_type = Machine + + +class MachineTypeAutocomplete(AutocompleteViewMixin): + obj_type = MachineType + + +class IpTypeAutocomplete(AutocompleteViewMixin): + obj_type = IpType + + +class ExtensionAutocomplete(AutocompleteViewMixin): + obj_type = Extension + + +class DomainAutocomplete(AutocompleteViewMixin): + obj_type = Domain + + +class OuverturePortListAutocomplete(AutocompleteViewMixin): + obj_type = OuverturePortList + + +class InterfaceAutocomplete(AutocompleteViewMixin): + obj_type = Interface + + # Precision on search to add annotations so search behaves more like users expect it to + def filter_results(self): + if self.q: + self.query_set = self.query_set.filter( + Q(domain__name__icontains=self.q) | Q(machine__name__icontains=self.q) + ) + + +class IpListAutocomplete(AutocompleteViewMixin): + obj_type = IpList + + # Precision on search to add annotations so search behaves more like users expect it to + def filter_results(self): + machine_type = self.forwarded.get("machine_type", None) + self.query_set = self.query_set.filter(interface__isnull=True) + + if machine_type: + self.query_set = self.query_set.filter( + ip_type__machinetype__id=machine_type + ) + + if self.q: + self.query_set = self.query_set.filter(Q(ipv4__startswith=self.q)) diff --git a/multi_op/forms.py b/multi_op/forms.py index 59043287..f4c839de 100644 --- a/multi_op/forms.py +++ b/multi_op/forms.py @@ -36,15 +36,17 @@ from topologie.models import Dormitory from .preferences.models import MultiopOption + class DormitoryForm(FormRevMixin, Form): """Form used to select dormitories.""" dormitory = forms.ModelMultipleChoiceField( - queryset=MultiopOption.get_cached_value("enabled_dorm").all(), label=_("Dormitory"), widget=forms.CheckboxSelectMultiple, required=False, + queryset=Dormitory.objects.none(), ) def __init__(self, *args, **kwargs): super(DormitoryForm, self).__init__(*args, **kwargs) + self.fields["dormitory"].queryset = MultiopOption.get_cached_value("enabled_dorm").all() diff --git a/multi_op/preferences/forms.py b/multi_op/preferences/forms.py index 406a9c3b..45941007 100644 --- a/multi_op/preferences/forms.py +++ b/multi_op/preferences/forms.py @@ -29,6 +29,7 @@ each. from django import forms from django.forms import ModelForm, Form from django.utils.translation import ugettext_lazy as _ +from re2o.widgets import AutocompleteMultipleModelWidget from .models import MultiopOption @@ -39,3 +40,8 @@ class EditMultiopOptionForm(ModelForm): class Meta: model = MultiopOption fields = "__all__" + widgets = { + "enabled_dorm": AutocompleteMultipleModelWidget( + url="/topologie/dormitory-autocomplete", + ), + } diff --git a/pip_requirements.txt b/pip_requirements.txt index 2d0aba41..b0ceffe5 100644 --- a/pip_requirements.txt +++ b/pip_requirements.txt @@ -1,2 +1,3 @@ django-bootstrap3==11.1.0 django-macaddress==1.6.0 +django-autocomplete-light==3.8.1 diff --git a/preferences/forms.py b/preferences/forms.py index a0880fd6..e19737c1 100644 --- a/preferences/forms.py +++ b/preferences/forms.py @@ -30,6 +30,10 @@ from django.db.models import Q from django import forms from django.utils.translation import ugettext_lazy as _ from re2o.mixins import FormRevMixin +from re2o.widgets import ( + AutocompleteModelWidget, + AutocompleteMultipleModelWidget +) from .models import ( OptionalUser, OptionalMachine, @@ -108,12 +112,19 @@ class EditOptionalTopologieForm(ModelForm): """Form used to edit the configuration of switches.""" automatic_provision_switchs = forms.ModelMultipleChoiceField( - Switch.objects.all(), required=False + Switch.objects.all(), + required=False, + widget=AutocompleteMultipleModelWidget(url="/topologie/switch-autocomplete"), ) class Meta: model = OptionalTopologie fields = "__all__" + widgets = { + "switchs_ip_type": AutocompleteModelWidget( + url="/machines/iptype-autocomplete", + ), + } def __init__(self, *args, **kwargs): prefix = kwargs.pop("prefix", self.Meta.model.__name__) @@ -168,6 +179,11 @@ class EditAssoOptionForm(ModelForm): class Meta: model = AssoOption fields = "__all__" + widgets = { + "utilisateur_asso": AutocompleteModelWidget( + url="/users/user-autocomplete", + ), + } def __init__(self, *args, **kwargs): prefix = kwargs.pop("prefix", self.Meta.model.__name__) @@ -254,6 +270,11 @@ class MandateForm(ModelForm): class Meta: model = Mandate fields = "__all__" + widgets = { + "president": AutocompleteModelWidget( + url="/users/user-autocomplete", + ), + } def __init__(self, *args, **kwargs): prefix = kwargs.pop("prefix", self.Meta.model.__name__) @@ -368,7 +389,9 @@ class RadiusKeyForm(FormRevMixin, ModelForm): """Form used to add and edit RADIUS keys.""" members = forms.ModelMultipleChoiceField( - queryset=Switch.objects.all(), required=False + queryset=Switch.objects.all(), + required=False, + widget=AutocompleteMultipleModelWidget(url="/topologie/switch-autocomplete"), ) class Meta: @@ -391,7 +414,11 @@ class RadiusKeyForm(FormRevMixin, ModelForm): class SwitchManagementCredForm(FormRevMixin, ModelForm): """Form used to add and edit switch management credentials.""" - members = forms.ModelMultipleChoiceField(Switch.objects.all(), required=False) + members = forms.ModelMultipleChoiceField( + Switch.objects.all(), + required=False, + widget=AutocompleteMultipleModelWidget(url="/topologie/switch-autocomplete"), + ) class Meta: model = SwitchManagementCred diff --git a/preferences/templates/preferences/edit_preferences.html b/preferences/templates/preferences/edit_preferences.html index 2d9e1a62..c0fe2256 100644 --- a/preferences/templates/preferences/edit_preferences.html +++ b/preferences/templates/preferences/edit_preferences.html @@ -24,19 +24,19 @@ with this program; if not, write to the Free Software Foundation, Inc., {% endcomment %} {% load bootstrap3 %} -{% load massive_bootstrap_form %} {% load i18n %} {% block title %}{% trans "Preferences" %}{% endblock %} {% block content %} {% bootstrap_form_errors options %} +{{ options.media }}

{% trans "Editing of preferences" %}

{% csrf_token %} - {% massive_bootstrap_form options 'utilisateur_asso,automatic_provision_switchs' %} + {% bootstrap_form options %} {% if formset %} {{ formset.management_form }} {% for f in formset %} diff --git a/preferences/templates/preferences/preferences.html b/preferences/templates/preferences/preferences.html index dda6ddfa..d1409cb8 100644 --- a/preferences/templates/preferences/preferences.html +++ b/preferences/templates/preferences/preferences.html @@ -25,20 +25,20 @@ with this program; if not, write to the Free Software Foundation, Inc., {% load bootstrap3 %} {% load i18n %} -{% load massive_bootstrap_form %} {% block title %}{% trans "Preferences" %}{% endblock %} {% block content %} {% if preferenceform %} {% bootstrap_form_errors preferenceform %} +{{ preferenceform.media }} {% endif %} {% csrf_token %} {% if preferenceform %} - {% massive_bootstrap_form preferenceform 'members,president' %} + {% bootstrap_form preferenceform %} {% endif %} {% bootstrap_button action_name button_type="submit" icon='ok' button_class='btn-success' %} diff --git a/preferences/views.py b/preferences/views.py index 5b5f6b03..2be0cd2d 100644 --- a/preferences/views.py +++ b/preferences/views.py @@ -151,7 +151,8 @@ def display_options(request): optionnal_templates_list = [ app.preferences.views.aff_preferences(request) for app in optionnal_apps - if hasattr(app, "preferences") and hasattr(app.preferences.views, "aff_preferences") + if hasattr(app, "preferences") + and hasattr(app.preferences.views, "aff_preferences") ] return form( @@ -350,7 +351,10 @@ def add_switchmanagementcred(request): "The switch management credentials were added.")) return redirect(reverse("preferences:display-options")) return form( - {"preferenceform": switchmanagementcred, "action_name": _("Add"), }, + { + "preferenceform": switchmanagementcred, + "action_name": _("Add"), + }, "preferences/preferences.html", request, ) @@ -415,6 +419,10 @@ def add_mailcontact(request): return redirect(reverse("preferences:display-options")) return form( {"preferenceform": mailcontact, "action_name": _("Add"), }, + { + "preferenceform": mailcontact, + "action_name": _("Add"), + }, "preferences/preferences.html", request, ) diff --git a/re2o/acl.py b/re2o/acl.py index bc870905..314a2698 100644 --- a/re2o/acl.py +++ b/re2o/acl.py @@ -68,11 +68,17 @@ def acl_base_decorator(method_name, *targets, on_instance=True, api=False): """Base decorator for acl. It checks if the `request.user` has the permission by calling model.method_name. If the flag on_instance is True, tries to get an instance of the model by calling - `model.get_instance(*args, **kwargs)` and runs `instance.mehod_name` + `model.get_instance(obj_id, *args, **kwargs)` and runs `instance.mehod_name` rather than model.method_name. It is not intended to be used as is. It is a base for others ACL - decorators. + decorators. Beware, if you redefine the `get_instance` method for your + model, give it a signature such as + `def get_instance(cls, object_id, *_args, **_kwargs)`, because you will + likely have an url with a named parameter "userid" if *e.g.* your model + is an user. Otherwise, if the parameter name in `get_instance` was also + `userid`, then `get_instance` would end up having two identical parameter + passed on, and this would result in a `TypeError` exception. Args: method_name: The name of the method which is to to be used for ACL. @@ -176,8 +182,7 @@ ModelC) # `wrapper` inside the `decorator` function, you need to read some #  documentation on decorators ! def decorator(view): - """The decorator to use on a specific view - """ + """The decorator to use on a specific view""" def wrapper(request, *args, **kwargs): """The wrapper used for a specific request""" @@ -259,18 +264,24 @@ ModelC) for msg in error_messages: messages.error( request, - msg or _( - "You don't have the right to access this menu."), + msg or _("You don't have the right to access this menu."), ) # And redirect the user to the right place. if request.user.id is not None: if not api: return redirect( - reverse("users:profil", kwargs={ - "userid": str(request.user.id)}) + reverse( + "users:profil", kwargs={"userid": str(request.user.id)} + ) ) else: - return Response(data={"errors": error_messages, "warning": warning_messages}, status=403) + return Response( + data={ + "errors": error_messages, + "warning": warning_messages, + }, + status=403, + ) else: return redirect(reverse("index")) return view(request, *chain(instances, args), **kwargs) @@ -321,12 +332,10 @@ def can_delete_set(model): If none of them, return an error""" def decorator(view): - """The decorator to use on a specific view - """ + """The decorator to use on a specific view""" def wrapper(request, *args, **kwargs): - """The wrapper used for a specific request - """ + """The wrapper used for a specific request""" all_objects = model.objects.all() instances_id = [] for instance in all_objects: @@ -367,9 +376,17 @@ def can_view_all(*targets): return acl_base_decorator("can_view_all", *targets, on_instance=False) -def can_view_app(*apps_name): - """Decorator to check if an user can view the applications. +def can_list(*targets): + """Decorator to check if an user can list a class of model. + It runs `acl_base_decorator` with the flag `on_instance=False` and the + method 'can_list'. See `acl_base_decorator` documentation for further + details. """ + return acl_base_decorator("can_list", *targets, on_instance=False) + + +def can_view_app(*apps_name): + """Decorator to check if an user can view the applications.""" for app_name in apps_name: assert app_name in sys.modules.keys() return acl_base_decorator( @@ -383,8 +400,7 @@ def can_edit_history(view): """Decorator to check if an user can edit history.""" def wrapper(request, *args, **kwargs): - """The wrapper used for a specific request - """ + """The wrapper used for a specific request""" if request.user.has_perm("admin.change_logentry"): return view(request, *args, **kwargs) messages.error(request, _( diff --git a/re2o/mixins.py b/re2o/mixins.py index 46203525..ebd35251 100644 --- a/re2o/mixins.py +++ b/re2o/mixins.py @@ -30,9 +30,9 @@ from django.utils.translation import ugettext as _ class RevMixin(object): - """ A mixin to subclass the save and delete function of a model + """A mixin to subclass the save and delete function of a model to enforce the versioning of the object before those actions - really happen """ + really happen""" def save(self, *args, **kwargs): """ Creates a version of this object and save it to database """ @@ -50,8 +50,8 @@ class RevMixin(object): class FormRevMixin(object): - """ A mixin to subclass the save function of a form - to enforce the versionning of the object before it is really edited """ + """A mixin to subclass the save function of a form + to enforce the versionning of the object before it is really edited""" def save(self, *args, **kwargs): """ Create a version of this object and save it to database """ @@ -81,6 +81,8 @@ class AclMixin(object): :can_view: Applied on an instance, return if the user can view the instance :can_view_all: Applied on a class, return if the user can view all + instances + :can_list: Applied on a class, return if the user can list all instances""" @classmethod @@ -131,7 +133,7 @@ class AclMixin(object): Parameters: user_request: User calling for this action - self: Instance to edit + self: Instance to edit Returns: Boolean: True if user_request has the right access to do it, else @@ -152,7 +154,7 @@ class AclMixin(object): Parameters: user_request: User calling for this action - self: Instance to delete + self: Instance to delete Returns: Boolean: True if user_request has the right access to do it, else @@ -210,12 +212,34 @@ class AclMixin(object): (permission,), ) + @classmethod + def can_list(cls, user_request, *_args, **_kwargs): + """Check if a user can list all instances of an object + + Parameters: + user_request: User calling for this action + + Returns: + Boolean: True if user_request has the right access to do it, else + false with reason for reject authorization + """ + permission = cls.get_modulename() + ".view_" + cls.get_classname() + can = user_request.has_perm(permission) + return ( + can, + _("You don't have the right to list every %s object.") % cls.get_classname() + if not can + else None, + (permission,), + cls.objects.all() if can else None, + ) + def can_view(self, user_request, *_args, **_kwargs): """Check if a user can view an instance of an object Parameters: user_request: User calling for this action - self: Instance to view + self: Instance to view Returns: Boolean: True if user_request has the right access to do it, else diff --git a/re2o/settings.py b/re2o/settings.py index f7975ae4..2e3d75a7 100644 --- a/re2o/settings.py +++ b/re2o/settings.py @@ -59,6 +59,8 @@ LOGIN_URL = "/login/" # The URL for login page LOGIN_REDIRECT_URL = "/" # The URL for redirecting after login # Application definition +# dal_legacy_static only needed for Django < 2.0 (https://django-autocomplete-light.readthedocs.io/en/master/install.html#django-versions-earlier-than-2-0) +EARLY_EXTERNAL_CONTRIB_APPS = ("dal", "dal_select2", "dal_legacy_static") # Need to be added before django.contrib.admin (https://django-autocomplete-light.readthedocs.io/en/master/install.html#configuration) DJANGO_CONTRIB_APPS = ( "django.contrib.admin", "django.contrib.auth", @@ -80,7 +82,7 @@ LOCAL_APPS = ( "logs", ) INSTALLED_APPS = ( - DJANGO_CONTRIB_APPS + EXTERNAL_CONTRIB_APPS + LOCAL_APPS + OPTIONNAL_APPS + EARLY_EXTERNAL_CONTRIB_APPS + DJANGO_CONTRIB_APPS + EXTERNAL_CONTRIB_APPS + LOCAL_APPS + OPTIONNAL_APPS ) MIDDLEWARE_CLASSES = ( "django.contrib.sessions.middleware.SessionMiddleware", diff --git a/re2o/templatetags/acl.py b/re2o/templatetags/acl.py index 132239be..4c88b207 100644 --- a/re2o/templatetags/acl.py +++ b/re2o/templatetags/acl.py @@ -141,6 +141,8 @@ def get_callback(tag_name, obj=None): return acl_fct(obj.can_view_all, False) if tag_name == "cannot_view_all": return acl_fct(obj.can_view_all, True) + if tag_name == "can_list": + return acl_fct(obj.can_list, False) if tag_name == "can_view_app": return acl_fct( lambda x: ( @@ -296,6 +298,7 @@ def acl_change_filter(parser, token): @register.tag("cannot_delete_all") @register.tag("can_view_all") @register.tag("cannot_view_all") +@register.tag("can_list") def acl_model_filter(parser, token): """Generic definition of an acl templatetag for acl based on model""" diff --git a/re2o/templatetags/massive_bootstrap_form.py b/re2o/templatetags/massive_bootstrap_form.py deleted file mode 100644 index 449f7c24..00000000 --- a/re2o/templatetags/massive_bootstrap_form.py +++ /dev/null @@ -1,752 +0,0 @@ -# -*- mode: python; coding: utf-8 -*- -# Re2o est un logiciel d'administration développé initiallement au Rézo Metz. Il -# se veut agnostique au réseau considéré, de manière à être installable en -# quelques clics. -# -# Copyright © 2017 Maël Kervella -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, write to the Free Software Foundation, Inc., -# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -""" Templatetag used to render massive django form selects into bootstrap -forms that can still be manipulating even if there is multiple tens of -thousands of elements in the select. It's made possible using JS libaries -Twitter Typeahead and Splitree's Tokenfield. -See docstring of massive_bootstrap_form for a detailed explaantion on how -to use this templatetag. -""" - -from django import template -from django.utils.safestring import mark_safe -from django.forms import TextInput -from django.forms.widgets import Select -from django.utils.translation import ugettext_lazy as _ -from bootstrap3.utils import render_tag -from bootstrap3.forms import render_field - -register = template.Library() - - -@register.simple_tag -def massive_bootstrap_form(form, mbf_fields, *args, **kwargs): - """ - Render a form where some specific fields are rendered using Twitter - Typeahead and/or splitree's Bootstrap Tokenfield to improve the - performance, the speed and UX when dealing with very large datasets - (select with 50k+ elts for instance). - When the fields specified should normally be rendered as a select with - single selectable option, Twitter Typeahead is used for a better display - and the matching query engine. When dealing with multiple selectable - options, sliptree's Bootstrap Tokenfield in addition with Typeahead. - For convenience, it accepts the same parameters as a standard bootstrap - can accept. - - **Tag name**:: - - massive_bootstrap_form - - **Parameters**: - - form (required) - The form that is to be rendered - - mbf_fields (optional) - A list of field names (comma separated) that should be rendered - with Typeahead/Tokenfield instead of the default bootstrap - renderer. - If not specified, all fields will be rendered as a normal bootstrap - field. - - mbf_param (optional) - A dict of parameters for the massive_bootstrap_form tag. The - possible parameters are the following. - - choices (optional) - A dict of strings representing the choices in JS. The keys of - the dict are the names of the concerned fields. The choices - must be an array of objects. Each of those objects must at - least have the fields 'key' (value to send) and 'value' (value - to display). Other fields can be added as desired. - For a more complex structure you should also consider - reimplementing the engine and the match_func. - If not specified, the key is the id of the object and the value - is its string representation as in a normal bootstrap form. - Example : - 'choices' : { - 'field_A':'[{key:0,value:"choice0",extra:"data0"},{...},...]', - 'field_B':..., - ... - } - - engine (optional) - A dict of strings representating the engine used for matching - queries and possible values with typeahead. The keys of the - dict are the names of the concerned fields. The string is valid - JS code. - If not specified, BloodHound with relevant basic properties is - used. - Example : - 'engine' : {'field_A': 'new Bloodhound()', 'field_B': ..., ...} - - match_func (optional) - A dict of strings representing a valid JS function used in the - dataset to overload the matching engine. The keys of the dict - are the names of the concerned fields. This function is used - the source of the dataset. This function receives 2 parameters, - the query and the synchronize function as specified in - typeahead.js documentation. If needed, the local variables - 'choices_' and 'engine_' contains - respectively the array of all possible values and the engine - to match queries with possible values. - If not specified, the function used display up to the 10 first - elements if the query is empty and else the matching results. - Example : - 'match_func' : { - 'field_A': 'function(q, sync) { engine.search(q, sync); }', - 'field_B': ..., - ... - } - - update_on (optional) - A dict of list of ids that the values depends on. The engine - and the typeahead properties are recalculated and reapplied. - Example : - 'update_on' : { - 'field_A' : [ 'id0', 'id1', ... ] , - 'field_B' : ... , - ... - } - - gen_select (optional) - A dict of boolean telling if the form should either generate - the normal select (set to true) and then use it to generate - the possible choices and then remove it or either (set to - false) generate the choices variable in this tag and do not - send any select. - Sending the select before can be usefull to permit the use - without any JS enabled but it will execute more code locally - for the client so the loading might be slower. - If not specified, this variable is set to true for each field - Example : - 'gen_select' : { - 'field_A': True , - 'field_B': ... , - ... - } - - See boostrap_form_ for other arguments - - **Usage**:: - - {% massive_bootstrap_form - form - [ '[,[,...]]' ] - [ mbf_param = { - [ 'choices': { - [ '': '' - [, '': '' - [, ... ] ] ] - } ] - [, 'engine': { - [ '': '' - [, '': '' - [, ... ] ] ] - } ] - [, 'match_func': { - [ '': '' - [, '': '' - [, ... ] ] ] - } ] - [, 'update_on': { - [ '': '' - [, '': '' - [, ... ] ] ] - } ], - [, 'gen_select': { - [ '': '' - [, '': '' - [, ... ] ] ] - } ] - } ] - [ ] - %} - - **Example**: - - {% massive_bootstrap_form form 'ipv4' choices='[...]' %} - """ - - mbf_form = MBFForm(form, mbf_fields.split(","), *args, **kwargs) - return mbf_form.render() - - -class MBFForm: - """ An object to hold all the information and useful methods needed to - create and render a massive django form into an actual HTML and JS - code able to handle it correctly. - Every field that is not listed is rendered as a normal bootstrap_field. - """ - - def __init__(self, form, mbf_fields, *args, **kwargs): - # The django form object - self.form = form - # The fields on which to use JS - self.fields = mbf_fields - - # Other bootstrap_form arguments to render the fields - self.args = args - self.kwargs = kwargs - - # Fields to exclude form the form rendering - self.exclude = self.kwargs.get("exclude", "").split(",") - - # All the mbf parameters specified byt the user - param = kwargs.pop("mbf_param", {}) - self.choices = param.get("choices", {}) - self.engine = param.get("engine", {}) - self.match_func = param.get("match_func", {}) - self.update_on = param.get("update_on", {}) - self.gen_select = param.get("gen_select", {}) - self.hidden_fields = [h.name for h in self.form.hidden_fields()] - - # HTML code to insert inside a template - self.html = "" - - def render(self): - """ HTML code for the fully rendered form with all the necessary form - """ - for name, field in self.form.fields.items(): - if name not in self.exclude: - - if name in self.fields and name not in self.hidden_fields: - mbf_field = MBFField( - name, - field, - field.get_bound_field(self.form, name), - self.choices.get(name, None), - self.engine.get(name, None), - self.match_func.get(name, None), - self.update_on.get(name, None), - self.gen_select.get(name, True), - *self.args, - **self.kwargs - ) - self.html += mbf_field.render() - - else: - f = field.get_bound_field(self.form, name), self.args, self.kwargs - self.html += render_field( - field.get_bound_field(self.form, name), - *self.args, - **self.kwargs - ) - - return mark_safe(self.html) - - -class MBFField: - """ An object to hold all the information and useful methods needed to - create and render a massive django form field into an actual HTML and JS - code able to handle it correctly. - Twitter Typeahead is used for the display and the matching of queries and - in case of a MultipleSelect, Sliptree's Tokenfield is also used to manage - multiple values. - A div with only non visible elements is created after the div containing - the displayed input. It's used to store the actual data that will be sent - to the server """ - - def __init__( - self, - name_, - field_, - bound_, - choices_, - engine_, - match_func_, - update_on_, - gen_select_, - *args_, - **kwargs_ - ): - - # Verify this field is a Select (or MultipleSelect) (only supported) - if not isinstance(field_.widget, Select): - raise ValueError( - ( - "Field named {f_name} is not a Select and" - "can't be rendered with massive_bootstrap_form." - ).format(f_name=name_) - ) - - # Name of the field - self.name = name_ - # Django field object - self.field = field_ - # Bound Django field associated with field - self.bound = bound_ - - # Id for the main visible input - self.input_id = self.bound.auto_id - # Id for a hidden input used to store the value - self.hidden_id = self.input_id + "_hidden" - # Id for another div containing hidden inputs and script - self.div2_id = self.input_id + "_div" - - # Should the standard select should be generated - self.gen_select = gen_select_ - # Is it select with multiple values possible (use of tokenfield) - self.multiple = self.field.widget.allow_multiple_selected - # JS for the choices variable (user specified or default) - self.choices = choices_ or self.default_choices() - # JS for the engine variable (typeahead) (user specified or default) - self.engine = engine_ or self.default_engine() - # JS for the matching function (typeahead) (user specified or default) - self.match_func = match_func_ or self.default_match_func() - # JS for the datasets variable (typeahead) (user specified or default) - self.datasets = self.default_datasets() - # Ids of other fields to bind a reset/reload with when changed - self.update_on = update_on_ or [] - - # Whole HTML code to insert in the template - self.html = "" - # JS code in the script tag - self.js_script = "" - # Input tag to display instead of select - self.replace_input = None - - # Other bootstrap_form arguments to render the fields - self.args = args_ - self.kwargs = kwargs_ - - def default_choices(self): - """ JS code of the variable choices_ """ - - if self.gen_select: - return ( - "function plop(o) {{" - "var c = [];" - "for( let i=0 ; i """ - return ( - "new Bloodhound({{" - ' datumTokenizer: Bloodhound.tokenizers.obj.whitespace("value"),' - " queryTokenizer: Bloodhound.tokenizers.whitespace," - " local: choices_{name}," - " identify: function(obj) {{ return obj.key; }}" - "}})" - ).format(name=self.name) - - def default_datasets(self): - """ Default JS script of the datasets to use with typeahead """ - return ( - "{{" - " hint: true," - " highlight: true," - " minLength: 0" - "}}," - "{{" - ' display: "value",' - ' name: "{name}",' - " source: {match_func}" - "}}" - ).format(name=self.name, match_func=self.match_func) - - def default_match_func(self): - """ Default JS code of the matching function to use with typeahed """ - return ( - "function ( q, sync ) {{" - ' if ( q === "" ) {{' - " var first = choices_{name}.slice( 0, 5 ).map(" - " function ( obj ) {{ return obj.key; }}" - " );" - " sync( engine_{name}.get( first ) );" - " }} else {{" - " engine_{name}.search( q, sync );" - " }}" - "}}" - ).format(name=self.name) - - def render(self): - """ HTML code for the fully rendered field """ - self.gen_displayed_div() - self.gen_hidden_div() - return mark_safe(self.html) - - def gen_displayed_div(self): - """ Generate HTML code for the div that contains displayed tags """ - if self.gen_select: - self.html += render_field(self.bound, *self.args, **self.kwargs) - - self.field.widget = TextInput( - attrs={ - "name": "mbf_" + self.name, - "placeholder": getattr(self.field, "empty_label", _("Nothing")), - } - ) - self.replace_input = render_field(self.bound, *self.args, **self.kwargs) - - if not self.gen_select: - self.html += self.replace_input - - def gen_hidden_div(self): - """ Generate HTML code for the div that contains hidden tags """ - self.gen_full_js() - - content = self.js_script - if not self.multiple and not self.gen_select: - content += self.hidden_input() - - self.html += render_tag("div", content=content, attrs={"id": self.div2_id}) - - def hidden_input(self): - """ HTML for the hidden input element """ - return render_tag( - "input", - attrs={ - "id": self.hidden_id, - "name": self.bound.html_name, - "type": "hidden", - "value": self.bound.value() or "", - }, - ) - - def gen_full_js(self): - """ Generate the full script tag containing the JS code """ - self.create_js() - self.fill_js() - self.get_script() - - def create_js(self): - """ Generate a template for the whole script to use depending on - gen_select and multiple """ - if self.gen_select: - if self.multiple: - self.js_script = ( - '$( "#{input_id}" ).ready( function() {{' - " var choices_{f_name} = {choices};" - " {del_select}" - " var engine_{f_name};" - " var setup_{f_name} = function() {{" - " engine_{f_name} = {engine};" - ' $( "#{input_id}" ).tokenfield( "destroy" );' - ' $( "#{input_id}" ).tokenfield({{typeahead: [ {datasets} ] }});' - " }};" - ' $( "#{input_id}" ).bind( "tokenfield:createtoken", {tok_create} );' - ' $( "#{input_id}" ).bind( "tokenfield:edittoken", {tok_edit} );' - ' $( "#{input_id}" ).bind( "tokenfield:removetoken", {tok_remove} );' - " {tok_updates}" - " setup_{f_name}();" - " {tok_init_input}" - "}} );" - ) - else: - self.js_script = ( - '$( "#{input_id}" ).ready( function() {{' - " var choices_{f_name} = {choices};" - " {del_select}" - " {gen_hidden}" - " var engine_{f_name};" - " var setup_{f_name} = function() {{" - " engine_{f_name} = {engine};" - ' $( "#{input_id}" ).typeahead( "destroy" );' - ' $( "#{input_id}" ).typeahead( {datasets} );' - " }};" - ' $( "#{input_id}" ).bind( "typeahead:select", {typ_select} );' - ' $( "#{input_id}" ).bind( "typeahead:change", {typ_change} );' - " {typ_updates}" - " setup_{f_name}();" - " {typ_init_input}" - "}} );" - ) - else: - if self.multiple: - self.js_script = ( - "var choices_{f_name} = {choices};" - "var engine_{f_name};" - "var setup_{f_name} = function() {{" - " engine_{f_name} = {engine};" - ' $( "#{input_id}" ).tokenfield( "destroy" );' - ' $( "#{input_id}" ).tokenfield({{typeahead: [ {datasets} ] }});' - "}};" - '$( "#{input_id}" ).bind( "tokenfield:createtoken", {tok_create} );' - '$( "#{input_id}" ).bind( "tokenfield:edittoken", {tok_edit} );' - '$( "#{input_id}" ).bind( "tokenfield:removetoken", {tok_remove} );' - "{tok_updates}" - '$( "#{input_id}" ).ready( function() {{' - " setup_{f_name}();" - " {tok_init_input}" - "}} );" - ) - else: - self.js_script = ( - "var choices_{f_name} ={choices};" - "var engine_{f_name};" - "var setup_{f_name} = function() {{" - " engine_{f_name} = {engine};" - ' $( "#{input_id}" ).typeahead( "destroy" );' - ' $( "#{input_id}" ).typeahead( {datasets} );' - "}};" - '$( "#{input_id}" ).bind( "typeahead:select", {typ_select} );' - '$( "#{input_id}" ).bind( "typeahead:change", {typ_change} );' - "{typ_updates}" - '$( "#{input_id}" ).ready( function() {{' - " setup_{f_name}();" - " {typ_init_input}" - "}} );" - ) - - # Make sure the visible element doesn't have the same name as the hidden elements - # Otherwise, in the POST request, they collide and an incoherent value is sent - self.js_script += ( - '$( "#{input_id}" ).ready( function() {{' - ' $( "#{input_id}" ).attr("name", "mbf_{f_name}");' - "}} );" - ) - - def fill_js(self): - """ Fill the template with the correct values """ - self.js_script = self.js_script.format( - f_name=self.name, - choices=self.choices, - del_select=self.del_select(), - gen_hidden=self.gen_hidden(), - engine=self.engine, - input_id=self.input_id, - datasets=self.datasets, - typ_select=self.typeahead_select(), - typ_change=self.typeahead_change(), - tok_create=self.tokenfield_create(), - tok_edit=self.tokenfield_edit(), - tok_remove=self.tokenfield_remove(), - typ_updates=self.typeahead_updates(), - tok_updates=self.tokenfield_updates(), - tok_init_input=self.tokenfield_init_input(), - typ_init_input=self.typeahead_init_input(), - ) - - def get_script(self): - """ Insert the JS code inside a script tag """ - self.js_script = render_tag("script", content=mark_safe(self.js_script)) - - def del_select(self): - """ JS code to delete the select if it has been generated and replace - it with an input. """ - return ( - 'var p = $("#{select_id}").parent()[0];' - "var new_input = `{replace_input}`;" - "p.innerHTML = new_input;" - ).format(select_id=self.input_id, replace_input=self.replace_input) - - def gen_hidden(self): - """ JS code to add a hidden tag to store the value. """ - return ( - 'var d = $("#{div2_id}")[0];' - 'var i = document.createElement("input");' - 'i.id = "{hidden_id}";' - 'i.name = "{html_name}";' - 'i.value = "";' - 'i.type = "hidden";' - "d.appendChild(i);" - ).format( - div2_id=self.div2_id, - hidden_id=self.hidden_id, - html_name=self.bound.html_name, - ) - - def typeahead_init_input(self): - """ JS code to init the fields values """ - init_key = self.bound.value() or '""' - return ( - '$( "#{input_id}" ).typeahead("val", {init_val});' - '$( "#{hidden_id}" ).val( {init_key} );' - ).format( - input_id=self.input_id, - init_val='""' - if init_key == '""' - else "engine_{name}.get( {init_key} )[0].value".format( - name=self.name, init_key=init_key - ), - init_key=init_key, - hidden_id=self.hidden_id, - ) - - def typeahead_reset_input(self): - """ JS code to reset the fields values """ - return ( - '$( "#{input_id}" ).typeahead("val", "");' '$( "#{hidden_id}" ).val( "" );' - ).format(input_id=self.input_id, hidden_id=self.hidden_id) - - def typeahead_select(self): - """ JS code to create the function triggered when an item is selected - through typeahead """ - return ( - "function(evt, item) {{" - ' $( "#{hidden_id}" ).val( item.key );' - ' $( "#{hidden_id}" ).change();' - " return item;" - "}}" - ).format(hidden_id=self.hidden_id) - - def typeahead_change(self): - """ JS code of the function triggered when an item is changed (i.e. - looses focus and value has changed since the moment it gained focus ) - """ - return ( - "function(evt) {{" - ' if ( $( "#{input_id}" ).typeahead( "val" ) === "" ) {{' - ' $( "#{hidden_id}" ).val( "" );' - ' $( "#{hidden_id}" ).change();' - " }}" - "}}" - ).format(input_id=self.input_id, hidden_id=self.hidden_id) - - def typeahead_updates(self): - """ JS code for binding external fields changes with a reset """ - reset_input = self.typeahead_reset_input() - updates = [ - ( - '$( "#{u_id}" ).change( function() {{' - " setup_{name}();" - " {reset_input}" - "}} );" - ).format(u_id=u_id, name=self.name, reset_input=reset_input) - for u_id in self.update_on - ] - return "".join(updates) - - def tokenfield_init_input(self): - """ JS code to init the fields values """ - init_key = self.bound.value() or '""' - return ('$( "#{input_id}" ).tokenfield("setTokens", {init_val});').format( - input_id=self.input_id, - init_val='""' - if init_key == '""' - else ( - "engine_{name}.get( {init_key} ).map(" - " function(o) {{ return o.value; }}" - ")" - ).format(name=self.name, init_key=init_key), - ) - - def tokenfield_reset_input(self): - """ JS code to reset the fields values """ - return ('$( "#{input_id}" ).tokenfield("setTokens", "");').format( - input_id=self.input_id - ) - - def tokenfield_create(self): - """ JS code triggered when a new token is created in tokenfield. """ - return ( - "function(evt) {{" - " var k = evt.attrs.key;" - " if (!k) {{" - " var data = evt.attrs.value;" - " var i = 0;" - " while ( i