8
0
Fork 0
mirror of https://gitlab2.federez.net/re2o/re2o synced 2024-11-24 20:33:11 +00:00
This commit is contained in:
root 2017-11-06 01:31:55 +01:00
commit 0e9baa606a
11 changed files with 448 additions and 194 deletions

View file

@ -36,7 +36,9 @@ def url_insert_param(url="", **kwargs):
Return the URL with some specific parameters inserted into the query
part. If a URL has already some parameters, those requested will be
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**::
@ -82,18 +84,21 @@ def url_insert_param(url="", **kwargs):
# Get existing parameters in the url
params = {}
if '?' in url:
url, params = url.split('?', maxsplit=1)
params = {
p[:p.find('=')]: p[p.find('=')+1:] for p in params.split('&')
}
url, parameters = url.split('?', maxsplit=1)
for parameter in parameters.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
for key, value in kwargs.items():
params[key] = value
params[key] = [value]
# Write the 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) + '&'
# Remove the last '&' (or '?' if no parameters)

View file

@ -248,7 +248,7 @@ class SortTable:
if not fields:
fields = values.get('default', [])
request = request.order_by(*fields)
if order == 'desc':
if values.get(col, None) and order == 'desc':
return request.reverse()
else:
return request

View file

@ -21,8 +21,8 @@
# with this program; if not, write to the Free Software Foundation, Inc.,
# 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 django.contrib import admin
# Register your models here.

View file

@ -20,21 +20,72 @@
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""The forms used by the search app"""
from __future__ import unicode_literals
from django.db.models import Q
from simple_search import BaseSearchForm
from django import forms
from django.forms import Form
from users.models import User, School
CHOICES_USER = (
('0', 'Actifs'),
('1', 'Désactivés'),
('2', 'Archivés'),
)
class UserSearchForm(BaseSearchForm):
class Meta:
base_qs = User.objects
search_fields = ('^name', 'description', 'specifications', '=id')
CHOICES_AFF = (
('0', 'Utilisateurs'),
('1', 'Machines'),
('2', 'Factures'),
('3', 'Bannissements'),
('4', 'Accès à titre gracieux'),
('5', 'Chambres'),
('6', 'Ports'),
('7', 'Switchs'),
)
# assumes a fulltext index has been defined on the fields
# 'name,description,specifications,id'
fulltext_indexes = (
('name', 2), # name matches are weighted higher
('name,description,specifications,id', 1),
def initial_choices(c):
"""Return the choices that should be activated by default for a
given set of choices"""
return [i[0] for i in c]
class SearchForm(Form):
"""The form for a simple search"""
q = forms.CharField(label='Search', max_length=100)
class SearchFormPlus(Form):
"""The form for an advanced search (with filters)"""
q = forms.CharField(
label='Search',
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"
)

View file

@ -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")

View file

@ -36,30 +36,35 @@ with this program; if not, write to the Free Software Foundation, Inc.,
<h2>Résultats dans les machines : </h2>
{% include "machines/aff_machines.html" with machines_list=machines_list %}
{% endif %}
{% if facture_list %}
{% if factures_list %}
<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_list %}
{% endif %}
{% if white_list %}
{% if whitelists_list %}
<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_list %}
{% endif %}
{% if ban_list %}
{% if bans_list %}
<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_list %}
{% endif %}
{% if switch_list %}
<h2>Résultats dans les switchs : </h2>
{% include "topologie/aff_switch.html" with switch_list=switch_list %}
{% if rooms_list %}
<h2>Résultats dans les chambres : </h2>
{% include "topologie/aff_chambres.html" with room_list=rooms_list %}
{% endif %}
{% if port_list %}
{% if switch_ports_list %}
<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=switch_ports_list %}
{% 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_list %}
<h2>Résultats dans les switchs : </h2>
{% include "topologie/aff_switch.html" with switch_list=switches_list %}
{% endif %}
{% if not users_list and not machines_list and not factures_list and not whitelists_list and not bans_list and not rooms_list and not switch_ports_list and not switches_list %}
<h3>Aucun résultat</h3>
{% endif %}
{% else %}
<h6>(Seulement les {{ max_result }} premiers résultats sont affichés dans chaque catégorie)</h6>
{% endif %}
<br />
<br />
<br />

View file

@ -28,11 +28,14 @@ with this program; if not, write to the Free Software Foundation, Inc.,
{% block title %}Recherche{% endblock %}
{% block content %}
{% bootstrap_form_errors searchform %}
{% bootstrap_form_errors search_form %}
<form class="form" method="post">
{% csrf_token %}
{% bootstrap_form searchform %}
<form class="form">
{% bootstrap_field search_form.q %}
{% include "buttons/multiple_checkbox_alt.html" with field=search_form.u %}
{% include "buttons/multiple_checkbox_alt.html" with field=search_form.a %}
{% bootstrap_field search_form.s %}
{% bootstrap_field search_form.e %}
{% bootstrap_button "Search" button_type="submit" icon="search" %}
</form>
<br />

View file

@ -20,6 +20,8 @@
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""The urls used by the search app"""
from __future__ import unicode_literals
from django.conf.urls import url
@ -28,5 +30,5 @@ from . import views
urlpatterns = [
url(r'^$', views.search, name='search'),
url(r'^avance/$', views.searchp, name='searchp'),
url(r'^advanced/$', views.searchp, name='searchp'),
]

View file

@ -20,115 +20,326 @@
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
# App de recherche pour re2o
# Augustin lemesle, Gabriel Détraz, Goulven Kermarec
# Gplv2
"""The views for the search app, responsible for finding the matches
Augustin lemesle, Gabriel Détraz, Goulven Kermarec, Maël Kervella
Gplv2"""
from __future__ import unicode_literals
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.db.models import Q
from users.models import User, Ban, Whitelist
from machines.models import Machine, Interface
from topologie.models import Port, Switch
from machines.models import Machine
from topologie.models import Port, Switch, Room
from cotisations.models import Facture
from search.models import SearchForm, SearchFormPlus
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):
date_deb = None
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)
def is_int(variable):
""" Check if the variable can be casted to an integer """
connexion = []
recherche = {'users_list': None, 'machines_list' : [], 'facture_list' : None, 'ban_list' : None, 'white_list': None, 'port_list': None, 'switch_list': None}
if request.user.has_perms(('cableur',)):
query = Q(user__pseudo__icontains = search) | Q(user__adherent__name__icontains = search) | Q(user__surname__icontains = search)
try:
int(variable)
except ValueError:
return False
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:
if i == '0':
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
if request.user.has_perms(('cableur',)):
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()
def get_results(query, request, filters={}):
""" 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."""
start = filters.get('s', None)
end = filters.get('e', None)
user_state = filters.get('u', initial_choices(CHOICES_USER))
aff = filters.get('a', initial_choices(CHOICES_AFF))
options, _ = GeneralOption.objects.get_or_create()
max_result = options.search_display_page
results = {
'users_list': User.objects.none(),
'machines_list': Machine.objects.none(),
'factures_list': Facture.objects.none(),
'bans_list': Ban.objects.none(),
'whitelists_list': Whitelist.objects.none(),
'rooms_list': Room.objects.none(),
'switch_ports_list': Port.objects.none(),
'switches_list': Switch.objects.none()
}
# Users
if '0' in aff:
filter_user_list = (
Q(
surname__icontains=query
) | Q(
adherent__name__icontains=query
) | Q(
pseudo__icontains=query
) | Q(
club__room__name__icontains=query
) | Q(
adherent__room__name__icontains=query
)
) & Q(state__in=user_state)
if not request.user.has_perms(('cableur',)):
recherche['port_list'] = None
if i == '6':
recherche['switch_list'] = Switch.objects.filter(details__icontains = search).distinct()
filter_user_list &= Q(id=request.user.id)
results['users_list'] = User.objects.filter(filter_user_list)
results['users_list'] = SortTable.sort(
results['users_list'],
request.GET.get('col'),
request.GET.get('order'),
SortTable.USERS_INDEX
)
# Machines
if '1' in aff:
filter_machine_list = Q(
name__icontains=query
) | (
Q(
user__pseudo__icontains=query
) & Q(
user__state__in=user_state
)
) | Q(
interface__domain__name__icontains=query
) | Q(
interface__domain__related_domain__name__icontains=query
) | Q(
interface__mac_address__icontains=query
) | Q(
interface__ipv4__ipv4__icontains=query
)
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
filter_machine_list &= Q(user__id=request.user.id)
results['machines_list'] = Machine.objects.filter(filter_machine_list)
results['machines_list'] = SortTable.sort(
results['machines_list'],
request.GET.get('col'),
request.GET.get('order'),
SortTable.MACHINES_INDEX
)
for r in recherche:
if recherche[r] != None:
recherche[r] = recherche[r][:search_display_page]
# Factures
if '2' in aff:
filter_facture_list = Q(
user__pseudo__icontains=query
) & Q(
user__state__in=user_state
)
if start is not None:
filter_facture_list &= Q(date__gte=start)
if end is not None:
filter_facture_list &= Q(date__lte=end)
results['factures_list'] = Facture.objects.filter(filter_facture_list)
results['factures_list'] = SortTable.sort(
results['factures_list'],
request.GET.get('col'),
request.GET.get('order'),
SortTable.COTISATIONS_INDEX
)
recherche.update({'max_result': search_display_page})
# Bans
if '3' in aff:
date_filter = (
Q(
user__pseudo__icontains=query
) & Q(
user__state__in=user_state
)
) | Q(
raison__icontains=query
)
if start is not None:
date_filter &= (
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:
date_filter &= (
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)
)
results['bans_list'] = Ban.objects.filter(date_filter)
results['bans_list'] = SortTable.sort(
results['bans_list'],
request.GET.get('col'),
request.GET.get('order'),
SortTable.USERS_INDEX_BAN
)
# Whitelists
if '4' in aff:
date_filter = (
Q(
user__pseudo__icontains=query
) & Q(
user__state__in=user_state
)
) | Q(
raison__icontains=query
)
if start is not None:
date_filter &= (
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:
date_filter &= (
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)
)
results['whitelists_list'] = Whitelist.objects.filter(date_filter)
results['whitelists_list'] = SortTable.sort(
results['whitelists_list'],
request.GET.get('col'),
request.GET.get('order'),
SortTable.USERS_INDEX_WHITE
)
# Rooms
if '5' in aff and request.user.has_perms(('cableur',)):
filter_rooms_list = Q(
details__icontains=query
) | Q(
name__icontains=query
) | Q(
port__details=query
)
results['rooms_list'] = Room.objects.filter(filter_rooms_list)
results['rooms_list'] = SortTable.sort(
results['rooms_list'],
request.GET.get('col'),
request.GET.get('order'),
SortTable.TOPOLOGIE_INDEX_ROOM
)
# Switch ports
if '6' in aff and request.user.has_perms(('cableur',)):
filter_ports_list = Q(
room__name__icontains=query
) | Q(
machine_interface__domain__name__icontains=query
) | Q(
related__switch__switch_interface__domain__name__icontains=query
) | Q(
radius__icontains=query
) | Q(
vlan_force__name__icontains=query
) | Q(
details__icontains=query
)
if is_int(query):
filter_ports_list |= Q(
port=query
)
results['switch_ports_list'] = Port.objects.filter(filter_ports_list)
results['switch_ports_list'] = SortTable.sort(
results['switch_ports_list'],
request.GET.get('col'),
request.GET.get('order'),
SortTable.TOPOLOGIE_INDEX_PORT
)
# Switches
if '7' in aff and request.user.has_perms(('cableur',)):
filter_switches_list = Q(
switch_interface__domain__name__icontains=query
) | Q(
switch_interface__ipv4__ipv4__icontains=query
) | Q(
location__icontains=query
) | Q(
stack__name__icontains=query
) | Q(
model__reference__icontains=query
) | Q(
model__constructor__name__icontains=query
) | Q(
details__icontains=query
)
if is_int(query):
filter_switches_list |= Q(
number=query
) | Q(
stack_member_id=query
)
results['switches_list'] = Switch.objects.filter(filter_switches_list)
results['switches_list'] = SortTable.sort(
results['switches_list'],
request.GET.get('col'),
request.GET.get('order'),
SortTable.TOPOLOGIE_INDEX
)
for name, val in results.items():
results[name] = val.distinct()[:max_result]
results.update({'max_result': max_result})
results.update({'search_term': query})
return results
return recherche
@login_required
def search(request):
search = SearchForm(request.POST or None)
if search.is_valid():
return form(search_result(search, False, request), 'search/index.html',request)
return form({'searchform' : search}, 'search/search.html', request)
""" La page de recherche standard """
search_form = SearchForm(request.GET or None)
if search_form.is_valid():
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
def searchp(request):
search = SearchFormPlus(request.POST or None)
if search.is_valid():
return form(search_result(search, True, request), 'search/index.html',request)
return form({'searchform' : search}, 'search/search.html', request)
""" La page de recherche avancée """
search_form = SearchFormPlus(request.GET or None)
if search_form.is_valid():
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})

View file

@ -73,10 +73,9 @@ with this program; if not, write to the Free Software Foundation, Inc.,
{% endif %}
</ul>
<div class="col-sm-3 col-md-3 navbar-right">
<form action="{% url "search:search"%}" method="POST" class="navbar-form" role="search">
{% csrf_token %}
<form action="{% url "search:search"%}" class="navbar-form" role="search">
<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">
<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>

View 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>