mirror of
https://gitlab2.federez.net/re2o/re2o
synced 2024-11-25 04:43:10 +00:00
Merge branch 'master' into reverse_url
This commit is contained in:
commit
1f293e99d7
15 changed files with 593 additions and 204 deletions
21
.gitlab-ci.yml
Normal file
21
.gitlab-ci.yml
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
---
|
||||||
|
image: debian:stretch
|
||||||
|
stages:
|
||||||
|
- lint
|
||||||
|
|
||||||
|
lint:
|
||||||
|
stage: lint
|
||||||
|
variables:
|
||||||
|
LANG: 'en_US.UTF-8'
|
||||||
|
LC_ALL: 'en_US.UTF-8'
|
||||||
|
LANGUAGE: 'en_US.UTF-8'
|
||||||
|
script:
|
||||||
|
- apt-get -qq update
|
||||||
|
- DEBIAN_FRONTEND=noninteractive apt-get -qq install -y locales python3-pip python3-django
|
||||||
|
- sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && dpkg-reconfigure --frontend=noninteractive locales && update-locale LANG=en_US.UTF-8
|
||||||
|
- pip3 install -q pylint-django
|
||||||
|
- pylint --load-plugins pylint_django cotisations machines re2o logs topologie preferences search users || if [ $? -ne 1 ]; then exit 0; else exit 1; fi
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -56,8 +56,10 @@ class NewFactureForm(ModelForm):
|
||||||
self.fields['banque'].empty_label = "Non renseigné"
|
self.fields['banque'].empty_label = "Non renseigné"
|
||||||
self.fields['paiement'].empty_label = "Séléctionner\
|
self.fields['paiement'].empty_label = "Séléctionner\
|
||||||
un moyen de paiement"
|
un moyen de paiement"
|
||||||
self.fields['paiement'].widget.attrs['data-cheque'] = Paiement.objects\
|
paiement_list = Paiement.objects.filter(type_paiement=1)
|
||||||
.filter(type_paiement=1).first().id
|
if paiement_list:
|
||||||
|
self.fields['paiement'].widget\
|
||||||
|
.attrs['data-cheque'] = paiement_list.first().id
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Facture
|
model = Facture
|
||||||
|
|
|
@ -154,11 +154,12 @@ def authorize(data):
|
||||||
else:
|
else:
|
||||||
nas_type = None
|
nas_type = None
|
||||||
if not nas_type or nas_type.port_access_mode == '802.1X':
|
if not nas_type or nas_type.port_access_mode == '802.1X':
|
||||||
user = data.get('User-Name', '')
|
user = data.get('User-Name', '').decode('utf-8', errors='replace')
|
||||||
user = user.split('@', 1)[0]
|
user = user.split('@', 1)[0]
|
||||||
mac = data.get('Calling-Station-Id', '')
|
mac = data.get('Calling-Station-Id', '')
|
||||||
result, log, password = check_user_machine_and_register(nas_type, user, mac)
|
result, log, password = check_user_machine_and_register(nas_type, user, mac)
|
||||||
logger.info(log.encode('utf-8'))
|
logger.info(log.encode('utf-8'))
|
||||||
|
logger.info(user.encode('utf-8'))
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
return radiusd.RLM_MODULE_REJECT
|
return radiusd.RLM_MODULE_REJECT
|
||||||
|
|
|
@ -36,7 +36,9 @@ def url_insert_param(url="", **kwargs):
|
||||||
Return the URL with some specific parameters inserted into the query
|
Return the URL with some specific parameters inserted into the query
|
||||||
part. If a URL has already some parameters, those requested will be
|
part. If a URL has already some parameters, those requested will be
|
||||||
modified if already exisiting or will be added and the other parameters
|
modified if already exisiting or will be added and the other parameters
|
||||||
will stay unmodified.
|
will stay unmodified. If parameters with the same name are already in the
|
||||||
|
URL and a value is specified for this parameter, it will replace all
|
||||||
|
existing parameters.
|
||||||
|
|
||||||
**Tag name**::
|
**Tag name**::
|
||||||
|
|
||||||
|
@ -82,18 +84,21 @@ def url_insert_param(url="", **kwargs):
|
||||||
# Get existing parameters in the url
|
# Get existing parameters in the url
|
||||||
params = {}
|
params = {}
|
||||||
if '?' in url:
|
if '?' in url:
|
||||||
url, params = url.split('?', maxsplit=1)
|
url, parameters = url.split('?', maxsplit=1)
|
||||||
params = {
|
for parameter in parameters.split('&'):
|
||||||
p[:p.find('=')]: p[p.find('=')+1:] for p in params.split('&')
|
p_name, p_value = parameter.split('=', maxsplit=1)
|
||||||
}
|
if p_name not in params:
|
||||||
|
params[p_name] = []
|
||||||
|
params[p_name].append(p_value)
|
||||||
|
|
||||||
# Add the request parameters to the list of parameters
|
# Add the request parameters to the list of parameters
|
||||||
for key, value in kwargs.items():
|
for key, value in kwargs.items():
|
||||||
params[key] = value
|
params[key] = [value]
|
||||||
|
|
||||||
# Write the url
|
# Write the url
|
||||||
url += '?'
|
url += '?'
|
||||||
for param, value in params.items():
|
for param, value_list in params.items():
|
||||||
|
for value in value_list:
|
||||||
url += str(param) + '=' + str(value) + '&'
|
url += str(param) + '=' + str(value) + '&'
|
||||||
|
|
||||||
# Remove the last '&' (or '?' if no parameters)
|
# Remove the last '&' (or '?' if no parameters)
|
||||||
|
|
|
@ -248,7 +248,7 @@ class SortTable:
|
||||||
if not fields:
|
if not fields:
|
||||||
fields = values.get('default', [])
|
fields = values.get('default', [])
|
||||||
request = request.order_by(*fields)
|
request = request.order_by(*fields)
|
||||||
if order == 'desc':
|
if values.get(col, None) and order == 'desc':
|
||||||
return request.reverse()
|
return request.reverse()
|
||||||
else:
|
else:
|
||||||
return request
|
return request
|
||||||
|
|
|
@ -21,8 +21,8 @@
|
||||||
# with this program; if not, write to the Free Software Foundation, Inc.,
|
# with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
|
||||||
|
"""The field used in the admin view for the search app"""
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
# Register your models here.
|
# Register your models here.
|
||||||
|
|
|
@ -20,21 +20,83 @@
|
||||||
# with this program; if not, write to the Free Software Foundation, Inc.,
|
# with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
|
||||||
|
"""The forms used by the search app"""
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from django.db.models import Q
|
from django import forms
|
||||||
from simple_search import BaseSearchForm
|
from django.forms import Form
|
||||||
|
|
||||||
from users.models import User, School
|
CHOICES_USER = (
|
||||||
|
('0', 'Actifs'),
|
||||||
class UserSearchForm(BaseSearchForm):
|
('1', 'Désactivés'),
|
||||||
class Meta:
|
('2', 'Archivés'),
|
||||||
base_qs = User.objects
|
)
|
||||||
search_fields = ('^name', 'description', 'specifications', '=id')
|
|
||||||
|
CHOICES_AFF = (
|
||||||
# assumes a fulltext index has been defined on the fields
|
('0', 'Utilisateurs'),
|
||||||
# 'name,description,specifications,id'
|
('1', 'Machines'),
|
||||||
fulltext_indexes = (
|
('2', 'Factures'),
|
||||||
('name', 2), # name matches are weighted higher
|
('3', 'Bannissements'),
|
||||||
('name,description,specifications,id', 1),
|
('4', 'Accès à titre gracieux'),
|
||||||
|
('5', 'Chambres'),
|
||||||
|
('6', 'Ports'),
|
||||||
|
('7', 'Switchs'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def initial_choices(choice_set):
|
||||||
|
"""Return the choices that should be activated by default for a
|
||||||
|
given set of choices"""
|
||||||
|
return [i[0] for i in choice_set]
|
||||||
|
|
||||||
|
|
||||||
|
class SearchForm(Form):
|
||||||
|
"""The form for a simple search"""
|
||||||
|
q = forms.CharField(
|
||||||
|
label='Recherche',
|
||||||
|
help_text=(
|
||||||
|
'Utilisez « » et «,» pour spécifier différents mots, «"query"» '
|
||||||
|
'pour une recherche exacte et «\\» pour échapper un caractère.'
|
||||||
|
),
|
||||||
|
max_length=100
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchFormPlus(Form):
|
||||||
|
"""The form for an advanced search (with filters)"""
|
||||||
|
q = forms.CharField(
|
||||||
|
label='Recherche',
|
||||||
|
help_text=(
|
||||||
|
'Utilisez « » et «,» pour spécifier différents mots, «"query"» '
|
||||||
|
'pour une recherche exacte et «\\» pour échapper un caractère.'
|
||||||
|
),
|
||||||
|
max_length=100,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
u = forms.MultipleChoiceField(
|
||||||
|
label="Filtre utilisateurs",
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxSelectMultiple,
|
||||||
|
choices=CHOICES_USER,
|
||||||
|
initial=initial_choices(CHOICES_USER)
|
||||||
|
)
|
||||||
|
a = forms.MultipleChoiceField(
|
||||||
|
label="Filtre affichage",
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxSelectMultiple,
|
||||||
|
choices=CHOICES_AFF,
|
||||||
|
initial=initial_choices(CHOICES_AFF)
|
||||||
|
)
|
||||||
|
s = forms.DateField(
|
||||||
|
required=False,
|
||||||
|
label="Date de début",
|
||||||
|
help_text='DD/MM/YYYY',
|
||||||
|
input_formats=['%d/%m/%Y']
|
||||||
|
)
|
||||||
|
e = forms.DateField(
|
||||||
|
required=False,
|
||||||
|
help_text='DD/MM/YYYY',
|
||||||
|
input_formats=['%d/%m/%Y'],
|
||||||
|
label="Date de fin"
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,62 +0,0 @@
|
||||||
# -*- mode: python; coding: utf-8 -*-
|
|
||||||
# Re2o est un logiciel d'administration développé initiallement au rezometz. Il
|
|
||||||
# se veut agnostique au réseau considéré, de manière à être installable en
|
|
||||||
# quelques clics.
|
|
||||||
#
|
|
||||||
# Copyright © 2017 Gabriel Détraz
|
|
||||||
# Copyright © 2017 Goulven Kermarec
|
|
||||||
# Copyright © 2017 Augustin Lemesle
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.db import models
|
|
||||||
from django import forms
|
|
||||||
from django.forms import Form
|
|
||||||
from django.forms import ModelForm
|
|
||||||
|
|
||||||
CHOICES = (
|
|
||||||
('0', 'Actifs'),
|
|
||||||
('1', 'Désactivés'),
|
|
||||||
('2', 'Archivés'),
|
|
||||||
)
|
|
||||||
|
|
||||||
CHOICES2 = (
|
|
||||||
(1, 'Active'),
|
|
||||||
("", 'Désactivée'),
|
|
||||||
)
|
|
||||||
|
|
||||||
CHOICES3 = (
|
|
||||||
('0', 'Utilisateurs'),
|
|
||||||
('1', 'Machines'),
|
|
||||||
('2', 'Factures'),
|
|
||||||
('3', 'Bannissements'),
|
|
||||||
('4', 'Accès à titre gracieux'),
|
|
||||||
('6', 'Switchs'),
|
|
||||||
('5', 'Ports'),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SearchForm(Form):
|
|
||||||
search_field = forms.CharField(label = 'Search', max_length = 100)
|
|
||||||
|
|
||||||
class SearchFormPlus(Form):
|
|
||||||
search_field = forms.CharField(label = 'Search', max_length = 100, required=False)
|
|
||||||
filtre = forms.MultipleChoiceField(label="Filtre utilisateurs", required=False, widget =forms.CheckboxSelectMultiple,choices=CHOICES)
|
|
||||||
connexion = forms.MultipleChoiceField(label="Filtre connexion", required=False, widget =forms.CheckboxSelectMultiple,choices=CHOICES2)
|
|
||||||
affichage = forms.MultipleChoiceField(label="Filtre affichage", required=False, widget =forms.CheckboxSelectMultiple,choices=CHOICES3)
|
|
||||||
date_deb = forms.DateField(required=False, label="Date de début", help_text='DD/MM/YYYY', input_formats=['%d/%m/%Y'])
|
|
||||||
date_fin = forms.DateField(required=False, help_text='DD/MM/YYYY', input_formats=['%d/%m/%Y'], label="Date de fin")
|
|
|
@ -28,38 +28,43 @@ with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
{% block title %}Résultats de la recherche{% endblock %}
|
{% block title %}Résultats de la recherche{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if users_list %}
|
{% if users %}
|
||||||
<h2>Résultats dans les utilisateurs</h2>
|
<h2>Résultats dans les utilisateurs</h2>
|
||||||
{% include "users/aff_users.html" with users_list=users_list %}
|
{% include "users/aff_users.html" with users_list=users %}
|
||||||
{% endif%}
|
{% endif%}
|
||||||
{% if machines_list %}
|
{% if machines %}
|
||||||
<h2>Résultats dans les machines : </h2>
|
<h2>Résultats dans les machines : </h2>
|
||||||
{% include "machines/aff_machines.html" with machines_list=machines_list %}
|
{% include "machines/aff_machines.html" with machines_list=machines %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if facture_list %}
|
{% if factures %}
|
||||||
<h2>Résultats dans les factures : </h2>
|
<h2>Résultats dans les factures : </h2>
|
||||||
{% include "cotisations/aff_cotisations.html" with facture_list=facture_list %}
|
{% include "cotisations/aff_cotisations.html" with facture_list=factures %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if white_list %}
|
{% if whitelists %}
|
||||||
<h2>Résultats dans les accès à titre gracieux : </h2>
|
<h2>Résultats dans les accès à titre gracieux : </h2>
|
||||||
{% include "users/aff_whitelists.html" with white_list=white_list %}
|
{% include "users/aff_whitelists.html" with white_list=whitelists %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if ban_list %}
|
{% if bans %}
|
||||||
<h2>Résultats dans les banissements : </h2>
|
<h2>Résultats dans les banissements : </h2>
|
||||||
{% include "users/aff_bans.html" with ban_list=ban_list %}
|
{% include "users/aff_bans.html" with ban_list=bans %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if switch_list %}
|
{% if rooms %}
|
||||||
<h2>Résultats dans les switchs : </h2>
|
<h2>Résultats dans les chambres : </h2>
|
||||||
{% include "topologie/aff_switch.html" with switch_list=switch_list %}
|
{% include "topologie/aff_chambres.html" with room_list=rooms %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if port_list %}
|
{% if ports %}
|
||||||
<h2>Résultats dans les ports : </h2>
|
<h2>Résultats dans les ports : </h2>
|
||||||
{% include "topologie/aff_port.html" with port_list=port_list %}
|
{% include "topologie/aff_port.html" with port_list=ports %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not ban_list and not interfaces_list and not users_list and not facture_list and not white_list and not port_list and not switch_list%}
|
{% if switches %}
|
||||||
|
<h2>Résultats dans les switchs : </h2>
|
||||||
|
{% include "topologie/aff_switch.html" with switch_list=switches %}
|
||||||
|
{% endif %}
|
||||||
|
{% if not users and not machines and not factures and not whitelists and not bans and not rooms and not ports and not switches %}
|
||||||
<h3>Aucun résultat</h3>
|
<h3>Aucun résultat</h3>
|
||||||
{% endif %}
|
{% else %}
|
||||||
<h6>(Seulement les {{ max_result }} premiers résultats sont affichés dans chaque catégorie)</h6>
|
<h6>(Seulement les {{ max_result }} premiers résultats sont affichés dans chaque catégorie)</h6>
|
||||||
|
{% endif %}
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
|
|
|
@ -28,11 +28,22 @@ with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
{% block title %}Recherche{% endblock %}
|
{% block title %}Recherche{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% bootstrap_form_errors searchform %}
|
{% bootstrap_form_errors search_form %}
|
||||||
|
|
||||||
<form class="form" method="post">
|
<form class="form">
|
||||||
{% csrf_token %}
|
{% bootstrap_field search_form.q %}
|
||||||
{% bootstrap_form searchform %}
|
{% if search_form.u %}
|
||||||
|
{% include "buttons/multiple_checkbox_alt.html" with field=search_form.u %}
|
||||||
|
{% endif %}
|
||||||
|
{% if search_form.a %}
|
||||||
|
{% include "buttons/multiple_checkbox_alt.html" with field=search_form.a %}
|
||||||
|
{% endif %}
|
||||||
|
{% if search_form.s %}
|
||||||
|
{% bootstrap_field search_form.s %}
|
||||||
|
{% endif %}
|
||||||
|
{% if search_form.e %}
|
||||||
|
{% bootstrap_field search_form.e %}
|
||||||
|
{% endif %}
|
||||||
{% bootstrap_button "Search" button_type="submit" icon="search" %}
|
{% bootstrap_button "Search" button_type="submit" icon="search" %}
|
||||||
</form>
|
</form>
|
||||||
<br />
|
<br />
|
||||||
|
|
|
@ -20,6 +20,8 @@
|
||||||
# with this program; if not, write to the Free Software Foundation, Inc.,
|
# with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
|
||||||
|
"""The urls used by the search app"""
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from django.conf.urls import url
|
from django.conf.urls import url
|
||||||
|
@ -28,5 +30,5 @@ from . import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r'^$', views.search, name='search'),
|
url(r'^$', views.search, name='search'),
|
||||||
url(r'^avance/$', views.searchp, name='searchp'),
|
url(r'^advanced/$', views.searchp, name='searchp'),
|
||||||
]
|
]
|
||||||
|
|
477
search/views.py
477
search/views.py
|
@ -20,115 +20,418 @@
|
||||||
# with this program; if not, write to the Free Software Foundation, Inc.,
|
# with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
|
||||||
# App de recherche pour re2o
|
"""The views for the search app, responsible for finding the matches
|
||||||
# Augustin lemesle, Gabriel Détraz, Goulven Kermarec
|
Augustin lemesle, Gabriel Détraz, Goulven Kermarec, Maël Kervella
|
||||||
# Gplv2
|
Gplv2"""
|
||||||
|
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.shortcuts import get_object_or_404
|
|
||||||
from django.template.context_processors import csrf
|
|
||||||
from django.template import Context, RequestContext, loader
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from users.models import User, Ban, Whitelist
|
from users.models import User, Ban, Whitelist
|
||||||
from machines.models import Machine, Interface
|
from machines.models import Machine
|
||||||
from topologie.models import Port, Switch
|
from topologie.models import Port, Switch, Room
|
||||||
from cotisations.models import Facture
|
from cotisations.models import Facture
|
||||||
from search.models import SearchForm, SearchFormPlus
|
|
||||||
from preferences.models import GeneralOption
|
from preferences.models import GeneralOption
|
||||||
|
from search.forms import (
|
||||||
|
SearchForm,
|
||||||
|
SearchFormPlus,
|
||||||
|
CHOICES_USER,
|
||||||
|
CHOICES_AFF,
|
||||||
|
initial_choices
|
||||||
|
)
|
||||||
|
from re2o.utils import SortTable
|
||||||
|
|
||||||
def form(ctx, template, request):
|
|
||||||
c = ctx
|
|
||||||
c.update(csrf(request))
|
|
||||||
return render(request, template, c)
|
|
||||||
|
|
||||||
def search_result(search, type, request):
|
def is_int(variable):
|
||||||
date_deb = None
|
""" Check if the variable can be casted to an integer """
|
||||||
date_fin = None
|
|
||||||
states=[]
|
|
||||||
co=[]
|
|
||||||
aff=[]
|
|
||||||
if(type):
|
|
||||||
aff = search.cleaned_data['affichage']
|
|
||||||
co = search.cleaned_data['connexion']
|
|
||||||
states = search.cleaned_data['filtre']
|
|
||||||
date_deb = search.cleaned_data['date_deb']
|
|
||||||
date_fin = search.cleaned_data['date_fin']
|
|
||||||
date_query = Q()
|
|
||||||
if aff==[]:
|
|
||||||
aff = ['0','1','2','3','4','5','6']
|
|
||||||
if date_deb != None:
|
|
||||||
date_query = date_query & Q(date__gte=date_deb)
|
|
||||||
if date_fin != None:
|
|
||||||
date_query = date_query & Q(date__lte=date_fin)
|
|
||||||
search = search.cleaned_data['search_field']
|
|
||||||
query1 = Q()
|
|
||||||
for s in states:
|
|
||||||
query1 = query1 | Q(state = s)
|
|
||||||
|
|
||||||
connexion = []
|
try:
|
||||||
|
int(variable)
|
||||||
recherche = {'users_list': None, 'machines_list' : [], 'facture_list' : None, 'ban_list' : None, 'white_list': None, 'port_list': None, 'switch_list': None}
|
except ValueError:
|
||||||
|
return False
|
||||||
if request.user.has_perms(('cableur',)):
|
|
||||||
query = Q(user__pseudo__icontains = search) | Q(user__adherent__name__icontains = search) | Q(user__surname__icontains = search)
|
|
||||||
else:
|
else:
|
||||||
query = (Q(user__pseudo__icontains = search) | Q(user__adherent__name__icontains = search) | Q(user__surname__icontains = search)) & Q(user = request.user)
|
return True
|
||||||
|
|
||||||
|
|
||||||
for i in aff:
|
def finish_results(results, col, order):
|
||||||
if i == '0':
|
"""Sort the results by applying filters and then limit them to the
|
||||||
query_user_list = Q(adherent__room__name__icontains = search) | Q(club__room__name__icontains = search) | Q(pseudo__icontains = search) | Q(adherent__name__icontains = search) | Q(surname__icontains = search) & query1
|
number of max results. Finally add the info of the nmax number of results
|
||||||
if request.user.has_perms(('cableur',)):
|
to the dict"""
|
||||||
recherche['users_list'] = User.objects.filter(query_user_list).order_by('state', 'surname').distinct()
|
|
||||||
else :
|
|
||||||
recherche['users_list'] = User.objects.filter(query_user_list & Q(id=request.user.id)).order_by('state', 'surname').distinct()
|
|
||||||
if i == '1':
|
|
||||||
query_machine_list = Q(machine__user__pseudo__icontains = search) | Q(machine__user__adherent__name__icontains = search) | Q(machine__user__surname__icontains = search) | Q(mac_address__icontains = search) | Q(ipv4__ipv4__icontains = search) | Q(domain__name__icontains = search) | Q(domain__related_domain__name__icontains = search)
|
|
||||||
if request.user.has_perms(('cableur',)):
|
|
||||||
data = Interface.objects.filter(query_machine_list).distinct()
|
|
||||||
else:
|
|
||||||
data = Interface.objects.filter(query_machine_list & Q(machine__user__id = request.user.id)).distinct()
|
|
||||||
for d in data:
|
|
||||||
recherche['machines_list'].append(d.machine)
|
|
||||||
if i == '2':
|
|
||||||
recherche['facture_list'] = Facture.objects.filter(query & date_query).distinct()
|
|
||||||
if i == '3':
|
|
||||||
recherche['ban_list'] = Ban.objects.filter(query).distinct()
|
|
||||||
if i == '4':
|
|
||||||
recherche['white_list'] = Whitelist.objects.filter(query).distinct()
|
|
||||||
if i == '5':
|
|
||||||
recherche['port_list'] = Port.objects.filter(details__icontains = search).distinct()
|
|
||||||
if not request.user.has_perms(('cableur',)):
|
|
||||||
recherche['port_list'] = None
|
|
||||||
if i == '6':
|
|
||||||
recherche['switch_list'] = Switch.objects.filter(details__icontains = search).distinct()
|
|
||||||
if not request.user.has_perms(('cableur',)):
|
|
||||||
recherche['switch_list'] = None
|
|
||||||
options, created = GeneralOption.objects.get_or_create()
|
|
||||||
search_display_page = options.search_display_page
|
|
||||||
|
|
||||||
for r in recherche:
|
results['users'] = SortTable.sort(
|
||||||
if recherche[r] != None:
|
results['users'],
|
||||||
recherche[r] = recherche[r][:search_display_page]
|
col,
|
||||||
|
order,
|
||||||
|
SortTable.USERS_INDEX
|
||||||
|
)
|
||||||
|
results['machines'] = SortTable.sort(
|
||||||
|
results['machines'],
|
||||||
|
col,
|
||||||
|
order,
|
||||||
|
SortTable.MACHINES_INDEX
|
||||||
|
)
|
||||||
|
results['factures'] = SortTable.sort(
|
||||||
|
results['factures'],
|
||||||
|
col,
|
||||||
|
order,
|
||||||
|
SortTable.COTISATIONS_INDEX
|
||||||
|
)
|
||||||
|
results['bans'] = SortTable.sort(
|
||||||
|
results['bans'],
|
||||||
|
col,
|
||||||
|
order,
|
||||||
|
SortTable.USERS_INDEX_BAN
|
||||||
|
)
|
||||||
|
results['whitelists'] = SortTable.sort(
|
||||||
|
results['whitelists'],
|
||||||
|
col,
|
||||||
|
order,
|
||||||
|
SortTable.USERS_INDEX_WHITE
|
||||||
|
)
|
||||||
|
results['rooms'] = SortTable.sort(
|
||||||
|
results['rooms'],
|
||||||
|
col,
|
||||||
|
order,
|
||||||
|
SortTable.TOPOLOGIE_INDEX_ROOM
|
||||||
|
)
|
||||||
|
results['ports'] = SortTable.sort(
|
||||||
|
results['ports'],
|
||||||
|
col,
|
||||||
|
order,
|
||||||
|
SortTable.TOPOLOGIE_INDEX_PORT
|
||||||
|
)
|
||||||
|
results['switches'] = SortTable.sort(
|
||||||
|
results['switches'],
|
||||||
|
col,
|
||||||
|
order,
|
||||||
|
SortTable.TOPOLOGIE_INDEX
|
||||||
|
)
|
||||||
|
|
||||||
recherche.update({'max_result': search_display_page})
|
options, _ = GeneralOption.objects.get_or_create()
|
||||||
|
max_result = options.search_display_page
|
||||||
|
for name, val in results.items():
|
||||||
|
results[name] = val.distinct()[:max_result]
|
||||||
|
results.update({'max_result': max_result})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def search_single_word(word, filters, is_cableur, user_id,
|
||||||
|
start, end, user_state, aff):
|
||||||
|
""" Construct the correct filters to match differents fields of some models
|
||||||
|
with the given query according to the given filters.
|
||||||
|
The match field are either CharField or IntegerField that will be displayed
|
||||||
|
on the results page (else, one might not see why a result has matched the
|
||||||
|
query). IntegerField are matched against the query only if it can be casted
|
||||||
|
to an int."""
|
||||||
|
|
||||||
|
# Users
|
||||||
|
if '0' in aff:
|
||||||
|
filter_users = (
|
||||||
|
Q(
|
||||||
|
surname__icontains=word
|
||||||
|
) | Q(
|
||||||
|
adherent__name__icontains=word
|
||||||
|
) | Q(
|
||||||
|
pseudo__icontains=word
|
||||||
|
) | Q(
|
||||||
|
club__room__name__icontains=word
|
||||||
|
) | Q(
|
||||||
|
adherent__room__name__icontains=word
|
||||||
|
)
|
||||||
|
) & Q(state__in=user_state)
|
||||||
|
if not is_cableur:
|
||||||
|
filter_users &= Q(id=user_id)
|
||||||
|
filters['users'] |= filter_users
|
||||||
|
|
||||||
|
# Machines
|
||||||
|
if '1' in aff:
|
||||||
|
filter_machines = Q(
|
||||||
|
name__icontains=word
|
||||||
|
) | (
|
||||||
|
Q(
|
||||||
|
user__pseudo__icontains=word
|
||||||
|
) & Q(
|
||||||
|
user__state__in=user_state
|
||||||
|
)
|
||||||
|
) | Q(
|
||||||
|
interface__domain__name__icontains=word
|
||||||
|
) | Q(
|
||||||
|
interface__domain__related_domain__name__icontains=word
|
||||||
|
) | Q(
|
||||||
|
interface__mac_address__icontains=word
|
||||||
|
) | Q(
|
||||||
|
interface__ipv4__ipv4__icontains=word
|
||||||
|
)
|
||||||
|
if not is_cableur:
|
||||||
|
filter_machines &= Q(user__id=user_id)
|
||||||
|
filters['machines'] |= filter_machines
|
||||||
|
|
||||||
|
# Factures
|
||||||
|
if '2' in aff:
|
||||||
|
filter_factures = Q(
|
||||||
|
user__pseudo__icontains=word
|
||||||
|
) & Q(
|
||||||
|
user__state__in=user_state
|
||||||
|
)
|
||||||
|
if start is not None:
|
||||||
|
filter_factures &= Q(date__gte=start)
|
||||||
|
if end is not None:
|
||||||
|
filter_factures &= Q(date__lte=end)
|
||||||
|
filters['factures'] |= filter_factures
|
||||||
|
|
||||||
|
# Bans
|
||||||
|
if '3' in aff:
|
||||||
|
filter_bans = (
|
||||||
|
Q(
|
||||||
|
user__pseudo__icontains=word
|
||||||
|
) & Q(
|
||||||
|
user__state__in=user_state
|
||||||
|
)
|
||||||
|
) | Q(
|
||||||
|
raison__icontains=word
|
||||||
|
)
|
||||||
|
if start is not None:
|
||||||
|
filter_bans &= (
|
||||||
|
Q(date_start__gte=start) & Q(date_end__gte=start)
|
||||||
|
) | (
|
||||||
|
Q(date_start__lte=start) & Q(date_end__gte=start)
|
||||||
|
) | (
|
||||||
|
Q(date_start__gte=start) & Q(date_end__lte=start)
|
||||||
|
)
|
||||||
|
if end is not None:
|
||||||
|
filter_bans &= (
|
||||||
|
Q(date_start__lte=end) & Q(date_end__lte=end)
|
||||||
|
) | (
|
||||||
|
Q(date_start__lte=end) & Q(date_end__gte=end)
|
||||||
|
) | (
|
||||||
|
Q(date_start__gte=end) & Q(date_end__lte=end)
|
||||||
|
)
|
||||||
|
filters['bans'] |= filter_bans
|
||||||
|
|
||||||
|
# Whitelists
|
||||||
|
if '4' in aff:
|
||||||
|
filter_whitelists = (
|
||||||
|
Q(
|
||||||
|
user__pseudo__icontains=word
|
||||||
|
) & Q(
|
||||||
|
user__state__in=user_state
|
||||||
|
)
|
||||||
|
) | Q(
|
||||||
|
raison__icontains=word
|
||||||
|
)
|
||||||
|
if start is not None:
|
||||||
|
filter_whitelists &= (
|
||||||
|
Q(date_start__gte=start) & Q(date_end__gte=start)
|
||||||
|
) | (
|
||||||
|
Q(date_start__lte=start) & Q(date_end__gte=start)
|
||||||
|
) | (
|
||||||
|
Q(date_start__gte=start) & Q(date_end__lte=start)
|
||||||
|
)
|
||||||
|
if end is not None:
|
||||||
|
filter_whitelists &= (
|
||||||
|
Q(date_start__lte=end) & Q(date_end__lte=end)
|
||||||
|
) | (
|
||||||
|
Q(date_start__lte=end) & Q(date_end__gte=end)
|
||||||
|
) | (
|
||||||
|
Q(date_start__gte=end) & Q(date_end__lte=end)
|
||||||
|
)
|
||||||
|
filters['whitelists'] |= filter_whitelists
|
||||||
|
|
||||||
|
# Rooms
|
||||||
|
if '5' in aff and is_cableur:
|
||||||
|
filter_rooms = Q(
|
||||||
|
details__icontains=word
|
||||||
|
) | Q(
|
||||||
|
name__icontains=word
|
||||||
|
) | Q(
|
||||||
|
port__details=word
|
||||||
|
)
|
||||||
|
filters['rooms'] |= filter_rooms
|
||||||
|
|
||||||
|
# Switch ports
|
||||||
|
if '6' in aff and is_cableur:
|
||||||
|
filter_ports = Q(
|
||||||
|
room__name__icontains=word
|
||||||
|
) | Q(
|
||||||
|
machine_interface__domain__name__icontains=word
|
||||||
|
) | Q(
|
||||||
|
related__switch__switch_interface__domain__name__icontains=word
|
||||||
|
) | Q(
|
||||||
|
radius__icontains=word
|
||||||
|
) | Q(
|
||||||
|
vlan_force__name__icontains=word
|
||||||
|
) | Q(
|
||||||
|
details__icontains=word
|
||||||
|
)
|
||||||
|
if is_int(word):
|
||||||
|
filter_ports |= Q(
|
||||||
|
port=word
|
||||||
|
)
|
||||||
|
filters['ports'] |= filter_ports
|
||||||
|
|
||||||
|
# Switches
|
||||||
|
if '7' in aff and is_cableur:
|
||||||
|
filter_switches = Q(
|
||||||
|
switch_interface__domain__name__icontains=word
|
||||||
|
) | Q(
|
||||||
|
switch_interface__ipv4__ipv4__icontains=word
|
||||||
|
) | Q(
|
||||||
|
location__icontains=word
|
||||||
|
) | Q(
|
||||||
|
stack__name__icontains=word
|
||||||
|
) | Q(
|
||||||
|
model__reference__icontains=word
|
||||||
|
) | Q(
|
||||||
|
model__constructor__name__icontains=word
|
||||||
|
) | Q(
|
||||||
|
details__icontains=word
|
||||||
|
)
|
||||||
|
if is_int(word):
|
||||||
|
filter_switches |= Q(
|
||||||
|
number=word
|
||||||
|
) | Q(
|
||||||
|
stack_member_id=word
|
||||||
|
)
|
||||||
|
filters['switches'] |= filter_switches
|
||||||
|
|
||||||
|
return filters
|
||||||
|
|
||||||
|
|
||||||
|
def get_words(query):
|
||||||
|
"""Function used to split the uery in different words to look for.
|
||||||
|
The rules are simple :
|
||||||
|
- anti-slash ('\\') is used to escape characters
|
||||||
|
- anything between quotation marks ('"') is kept intact (not
|
||||||
|
interpreted as separators) excepts anti-slashes used to escape
|
||||||
|
- spaces (' ') and commas (',') are used to separated words
|
||||||
|
"""
|
||||||
|
|
||||||
|
words = []
|
||||||
|
i = 0
|
||||||
|
keep_intact = False
|
||||||
|
escaping_char = False
|
||||||
|
for char in query:
|
||||||
|
if i >= len(words):
|
||||||
|
# We are starting a new word
|
||||||
|
words.append('')
|
||||||
|
if escaping_char:
|
||||||
|
# The last char war a \ so we escape this char
|
||||||
|
escaping_char = False
|
||||||
|
words[i] += char
|
||||||
|
continue
|
||||||
|
if char == '\\':
|
||||||
|
# We need to escape the next char
|
||||||
|
escaping_char = True
|
||||||
|
continue
|
||||||
|
if char == '"':
|
||||||
|
# Toogle the keep_intact state, if true, we are between two "
|
||||||
|
keep_intact = not keep_intact
|
||||||
|
continue
|
||||||
|
if keep_intact:
|
||||||
|
# If we are between two ", ignore separators
|
||||||
|
words[i] += char
|
||||||
|
continue
|
||||||
|
if char == ' ' or char == ',':
|
||||||
|
# If we encouter a separator outside of ", we create a new word
|
||||||
|
if words[i] is not '':
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
# If we haven't encountered any special case, add the char to the word
|
||||||
|
words[i] += char
|
||||||
|
|
||||||
|
return words
|
||||||
|
|
||||||
|
|
||||||
|
def get_results(query, request, params):
|
||||||
|
"""The main function of the search procedure. It gather the filters for
|
||||||
|
each of the different words of the query and concatenate them into a
|
||||||
|
single filter. Then it calls 'finish_results' and return the queryset of
|
||||||
|
objects to display as results"""
|
||||||
|
|
||||||
|
start = params.get('s', None)
|
||||||
|
end = params.get('e', None)
|
||||||
|
user_state = params.get('u', initial_choices(CHOICES_USER))
|
||||||
|
aff = params.get('a', initial_choices(CHOICES_AFF))
|
||||||
|
|
||||||
|
filters = {
|
||||||
|
'users': Q(),
|
||||||
|
'machines': Q(),
|
||||||
|
'factures': Q(),
|
||||||
|
'bans': Q(),
|
||||||
|
'whitelists': Q(),
|
||||||
|
'rooms': Q(),
|
||||||
|
'ports': Q(),
|
||||||
|
'switches': Q()
|
||||||
|
}
|
||||||
|
|
||||||
|
words = get_words(query)
|
||||||
|
for word in words:
|
||||||
|
filters = search_single_word(
|
||||||
|
word,
|
||||||
|
filters,
|
||||||
|
request.user.has_perms(('cableur',)),
|
||||||
|
request.user.id,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
user_state,
|
||||||
|
aff
|
||||||
|
)
|
||||||
|
|
||||||
|
results = {
|
||||||
|
'users': User.objects.filter(filters['users']),
|
||||||
|
'machines': Machine.objects.filter(filters['machines']),
|
||||||
|
'factures': Facture.objects.filter(filters['factures']),
|
||||||
|
'bans': Ban.objects.filter(filters['bans']),
|
||||||
|
'whitelists': Whitelist.objects.filter(filters['whitelists']),
|
||||||
|
'rooms': Room.objects.filter(filters['rooms']),
|
||||||
|
'ports': Port.objects.filter(filters['ports']),
|
||||||
|
'switches': Switch.objects.filter(filters['switches'])
|
||||||
|
}
|
||||||
|
|
||||||
|
results = finish_results(
|
||||||
|
results,
|
||||||
|
request.GET.get('col'),
|
||||||
|
request.GET.get('order')
|
||||||
|
)
|
||||||
|
results.update({'search_term': query})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
return recherche
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def search(request):
|
def search(request):
|
||||||
search = SearchForm(request.POST or None)
|
""" La page de recherche standard """
|
||||||
if search.is_valid():
|
search_form = SearchForm(request.GET or None)
|
||||||
return form(search_result(search, False, request), 'search/index.html',request)
|
if search_form.is_valid():
|
||||||
return form({'searchform' : search}, 'search/search.html', request)
|
return render(
|
||||||
|
request,
|
||||||
|
'search/index.html',
|
||||||
|
get_results(
|
||||||
|
search_form.cleaned_data.get('q', ''),
|
||||||
|
request,
|
||||||
|
search_form.cleaned_data
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return render(request, 'search/search.html', {'search_form': search_form})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def searchp(request):
|
def searchp(request):
|
||||||
search = SearchFormPlus(request.POST or None)
|
""" La page de recherche avancée """
|
||||||
if search.is_valid():
|
search_form = SearchFormPlus(request.GET or None)
|
||||||
return form(search_result(search, True, request), 'search/index.html',request)
|
if search_form.is_valid():
|
||||||
return form({'searchform' : search}, 'search/search.html', request)
|
return render(
|
||||||
|
request,
|
||||||
|
'search/index.html',
|
||||||
|
get_results(
|
||||||
|
search_form.cleaned_data.get('q', ''),
|
||||||
|
request,
|
||||||
|
search_form.cleaned_data
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return render(request, 'search/search.html', {'search_form': search_form})
|
||||||
|
|
|
@ -73,10 +73,9 @@ with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
<div class="col-sm-3 col-md-3 navbar-right">
|
<div class="col-sm-3 col-md-3 navbar-right">
|
||||||
<form action="{% url "search:search"%}" method="POST" class="navbar-form" role="search">
|
<form action="{% url "search:search"%}" class="navbar-form" role="search">
|
||||||
{% csrf_token %}
|
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="text" class="form-control" placeholder="Search" name="search_field" id="search-term">
|
<input type="text" class="form-control" placeholder="Search" name="q" id="search-term" {% if search_term %}value="{{ search_term }}"{% endif %}>
|
||||||
<div class="input-group-btn">
|
<div class="input-group-btn">
|
||||||
<button class="btn btn-default" type="submit"><i class="glyphicon glyphicon-search"></i></button>
|
<button class="btn btn-default" type="submit"><i class="glyphicon glyphicon-search"></i></button>
|
||||||
<a href="{% url "search:searchp" %}" class="btn btn-default" role="button"><i class="glyphicon glyphicon-plus"></i></a>
|
<a href="{% url "search:searchp" %}" class="btn btn-default" role="button"><i class="glyphicon glyphicon-plus"></i></a>
|
||||||
|
@ -199,7 +198,7 @@ with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
|
||||||
<footer class="navbar">
|
<footer class="navbar">
|
||||||
<div class="containerfluid text-center">
|
<div class="containerfluid text-center">
|
||||||
<p>Re2o 2016 - Gabriel Détraz, <a href="https://gitlab.rezometz.org/lhark">Goulven Kermarec</a>, Augustin Lemesle</p>
|
<p>Re2o 2016 - Gabriel Détraz, <a href="https://gitlab.rezometz.org/lhark">Goulven Kermarec</a>, Augustin Lemesle, Maël Kervella</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|
40
templates/buttons/multiple_checkbox_alt.html
Normal file
40
templates/buttons/multiple_checkbox_alt.html
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
{% comment %}
|
||||||
|
Re2o est un logiciel d'administration développé initiallement au rezometz. Il
|
||||||
|
se veut agnostique au réseau considéré, de manière à être installable en
|
||||||
|
quelques clics.
|
||||||
|
|
||||||
|
Copyright © 2017 Gabriel Détraz
|
||||||
|
Copyright © 2017 Goulven Kermarec
|
||||||
|
Copyright © 2017 Augustin Lemesle
|
||||||
|
|
||||||
|
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.
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
<div class="form-group {% if field.form.errors %}{% if field.errors %}has-error{% else %}has-success{% endif %}{% endif %}">
|
||||||
|
<label class="control-label" for="{{ field.id_for_label }}">
|
||||||
|
{{ field.label }}
|
||||||
|
</label>
|
||||||
|
<div id="{{ field.auto_id }}" data-toggle="buttons">
|
||||||
|
{% for val in field.field.choices %}
|
||||||
|
<label for="id_u_{{ val.0 }}" class="btn btn-default{% if val.0 in field.initial %} active{% endif %}">
|
||||||
|
<input {% if val.0 in field.initial %}checked="checked" {% endif %}class="" id="id_u_{{ val.0 }}" name="{{ field.name }}" title="" type="checkbox" value="{{ val.0 }}" /> {{ val.1 }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% for error in field.errors %}
|
||||||
|
<div class="help-block">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="help-block">{{ field.help_text }}</div>
|
||||||
|
</div>
|
Loading…
Reference in a new issue