From 7ea7086ba97b10d5b99dedd2452f00e4653de2cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20G=C3=B3mez?= Date: Wed, 27 Nov 2013 11:14:53 +0000 Subject: [PATCH] * Implemented services REST part * Added possibility of creating own types for DetailApi (useful for provicers/services, where services is a detail but also has types) More refactoring --- .../org.eclipse.core.resources.prefs | 1 + server/src/uds/REST/methods/providers.py | 16 +++- server/src/uds/REST/methods/services.py | 84 +++++++++++++++++ server/src/uds/REST/methods/transports.py | 3 +- server/src/uds/REST/mixins.py | 1 - server/src/uds/core/ui/UserInterface.py | 4 + server/src/uds/static/adm/css/uds-admin.css | 4 + server/src/uds/static/adm/js/api.js | 36 +++++--- .../src/uds/static/adm/js/gui-definition.js | 84 ++++++++++++----- server/src/uds/static/adm/js/gui-element.js | 17 ++-- server/src/uds/static/adm/js/gui-form.js | 92 +++++++++++++++++-- server/src/uds/static/adm/js/gui.js | 52 ----------- .../templates/uds/admin/tmpl/providers.html | 18 ++++ 13 files changed, 306 insertions(+), 106 deletions(-) create mode 100644 server/src/uds/REST/methods/services.py create mode 100644 server/src/uds/templates/uds/admin/tmpl/providers.html diff --git a/server/.settings/org.eclipse.core.resources.prefs b/server/.settings/org.eclipse.core.resources.prefs index 0b750aa73..93e19444c 100644 --- a/server/.settings/org.eclipse.core.resources.prefs +++ b/server/.settings/org.eclipse.core.resources.prefs @@ -15,6 +15,7 @@ encoding//src/uds/REST/methods/login_logout.py=utf-8 encoding//src/uds/REST/methods/networks.py=utf-8 encoding//src/uds/REST/methods/osmanagers.py=utf-8 encoding//src/uds/REST/methods/providers.py=utf-8 +encoding//src/uds/REST/methods/services.py=utf-8 encoding//src/uds/REST/methods/transports.py=utf-8 encoding//src/uds/REST/methods/users_groups.py=utf-8 encoding//src/uds/REST/mixins.py=utf-8 diff --git a/server/src/uds/REST/methods/providers.py b/server/src/uds/REST/methods/providers.py index 58b02b9b2..6342bccb3 100644 --- a/server/src/uds/REST/methods/providers.py +++ b/server/src/uds/REST/methods/providers.py @@ -34,6 +34,7 @@ from __future__ import unicode_literals from django.utils.translation import ugettext, ugettext_lazy as _ from uds.models import Provider +from services import Services from uds.core import services from uds.REST import Handler, NotFound @@ -46,12 +47,23 @@ logger = logging.getLogger(__name__) class Providers(ModelHandlerMixin, Handler): model = Provider + detail = { 'services': Services } + save_fields = ['name', 'comments'] def item_as_dict(self, provider): type_ = provider.getType() + + # Icon can have a lot of data (1-2 Kbytes), but it's not expected to have a lot of services providers, and even so, this will work fine + offers = [{ + 'name' : ugettext(t.name()), + 'type' : t.type(), + 'description' : ugettext(t.description()), + 'icon' : t.icon().replace('\n', '') } for t in type_.getServicesTypes()] + return { 'id': provider.id, 'name': provider.name, 'services_count': provider.services.count(), + 'offers': offers, 'type': type_.type(), 'comments': provider.comments, } @@ -64,13 +76,15 @@ class Types(ModelTypeHandlerMixin, Handler): def getGui(self, type_): try: - return services.factory().lookup(type_).guiDescription() + return self.addDefaultFields(services.factory().lookup(type_).guiDescription(), ['name', 'comments']) except: raise NotFound('type not found') class TableInfo(ModelTableHandlerMixin, Handler): path = 'providers' + detail = { 'services': Services } title = _('Current service providers') + fields = [ { 'name': {'title': _('Name'), 'type': 'iconType' } }, { 'comments': {'title': _('Comments')}}, diff --git a/server/src/uds/REST/methods/services.py b/server/src/uds/REST/methods/services.py new file mode 100644 index 000000000..8c8ee160b --- /dev/null +++ b/server/src/uds/REST/methods/services.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- + +# +# Copyright (c) 2014 Virtual Cable S.L. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of Virtual Cable S.L. nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +''' +@provideror: Adolfo Gómez, dkmaster at dkmon dot com +''' +from __future__ import unicode_literals + +from django.utils.translation import ugettext as _ +from uds.models import Provider + +from uds.REST.mixins import DetailHandler + +import logging + +logger = logging.getLogger(__name__) + + +class Services(DetailHandler): + + def get(self): + # Extract providerenticator + provider = self._kwargs['parent'] + + try: + if len(self._args) == 0: + return [{ + 'id':k.id, + 'name': k.name, + 'comments': k.comments, + 'type': k.data_type, + 'typeName' : _(k.getType().name()) + } for k in provider.services.all() ] + else: + with provider.get(pk=self._args[0]) as k: + return { + 'id':k.id, + 'name': k.name, + 'comments': k.comments, + 'type': k.data_type, + 'typeName' : _(k.getType().name()) + } + except: + logger.exception('En services') + return { 'error': 'not found' } + + def getTitle(self): + try: + return _('Services of {0}').format(Provider.objects.get(pk=self._kwargs['parent_id']).name) + except: + return _('Current services') + + def getFields(self): + return [ + { 'name': {'title': _('Service name'), 'visible': True, 'type': 'iconType' } }, + { 'comments': { 'title': _('Comments') } }, + { 'type': {'title': _('Type') } } + ] diff --git a/server/src/uds/REST/methods/transports.py b/server/src/uds/REST/methods/transports.py index 74653c315..3df664710 100644 --- a/server/src/uds/REST/methods/transports.py +++ b/server/src/uds/REST/methods/transports.py @@ -56,7 +56,7 @@ class Transports(ModelHandlerMixin, Handler): 'comments': item.comments, 'priority': item.priority, 'nets_positive': item.nets_positive, - 'networks': [ {'id': k.id} for k in item.networks.all() ], + 'networks': [ n.id for n in item.networks.all() ], 'deployed_count': item.deployedServices.count(), 'type': type_.type(), } @@ -64,7 +64,6 @@ class Transports(ModelHandlerMixin, Handler): class Types(ModelTypeHandlerMixin, Handler): path = 'transports' - has_comments = True def enum_types(self): return factory().providers().values() diff --git a/server/src/uds/REST/mixins.py b/server/src/uds/REST/mixins.py index e9faf6fc6..3979b40f4 100644 --- a/server/src/uds/REST/mixins.py +++ b/server/src/uds/REST/mixins.py @@ -183,7 +183,6 @@ class ModelTypeHandlerMixin(object): ''' authenticated = True needs_staff = True - has_comments = False def enum_types(self): pass diff --git a/server/src/uds/core/ui/UserInterface.py b/server/src/uds/core/ui/UserInterface.py index 08020ffa2..44ad550e7 100644 --- a/server/src/uds/core/ui/UserInterface.py +++ b/server/src/uds/core/ui/UserInterface.py @@ -767,6 +767,10 @@ class UserInterface(object): val = '\001' + cPickle.dumps(v.value) else: val = v.value + if val is True: + val = gui.TRUE + elif val is False: + val = gui.FALSE arr.append(k + '\003' + val) return '\002'.join(arr).encode('zip') diff --git a/server/src/uds/static/adm/css/uds-admin.css b/server/src/uds/static/adm/css/uds-admin.css index 62fdae212..c35184595 100644 --- a/server/src/uds/static/adm/css/uds-admin.css +++ b/server/src/uds/static/adm/css/uds-admin.css @@ -59,12 +59,16 @@ table.dataTable tr.even td.sorting_3 { background-color: blue; }*/ margin-bottom: 0.3em; } +/* modal dialogs & related*/ .modal-dialog { /* new custom width */ width: 60%; } +.tab-pane { + margin-top: 24px; +} .tooltip { z-index: 2014; } diff --git a/server/src/uds/static/adm/js/api.js b/server/src/uds/static/adm/js/api.js index 4287b7024..9adee01ed 100644 --- a/server/src/uds/static/adm/js/api.js +++ b/server/src/uds/static/adm/js/api.js @@ -331,16 +331,18 @@ BasicModelRest.prototype = { }); }, - detail: function(id, child) { + detail: function(id, child, options) { "use strict"; - return new DetailModelRestApi(this, id, child); + options = options || {}; + return new DetailModelRestApi(this, id, child, options); } }; // For REST of type /auth/[id]/users, /services/[id]/users, ... -function DetailModelRestApi(parentApi, parentId, model) { +function DetailModelRestApi(parentApi, parentId, model, options) { "use strict"; + this.options = options; this.base = new BasicModelRest(undefined, { getPath: [parentApi.path, parentId, model].join('/'), typesPath: '.', // We do not has this on details @@ -350,29 +352,33 @@ function DetailModelRestApi(parentApi, parentId, model) { DetailModelRestApi.prototype = { // Generates a basic model with fixed methods for "detail" models - get: function(options) { + get: function(success_fnc, fail_fnc) { "use strict"; - return this.base.get(options); + return this.base.get(success_fnc, fail_fnc); }, - tableInfo: function(options) { + tableInfo: function(success_fnc, fail_fnc) { "use strict"; - return this.base.tableInfo(options); + return this.base.tableInfo(success_fnc, fail_fnc); }, - list: function(success_fnc, options) { // This is "almost" an alias for get + list: function(success_fnc, fail_fnc) { // This is "almost" an alias for get "use strict"; - return this.base.list(success_fnc, options); + return this.base.list(success_fnc, fail_fnc); }, - overview: function(success_fnc, options) { + overview: function(success_fnc, fail_fnc) { "use strict"; - return this.base.overview(success_fnc, options); + return this.base.overview(success_fnc, fail_fnc); }, - item: function(itemId, success_fnc, options) { + item: function(itemId, success_fnc, fail_fnc) { "use strict"; - return this.base.item(success_fnc, options); + return this.base.item(success_fnc, fail_fnc); }, - types: function(options) { + types: function(success_fnc, fail_fnc) { "use strict"; - return this.base.types(options); + if( this.options.types ) { + this.options.types(success_fnc, fail_fnc); + } else { + return this.base.types(success_fnc, fail_fnc); + } }, }; diff --git a/server/src/uds/static/adm/js/gui-definition.js b/server/src/uds/static/adm/js/gui-definition.js index 56276846a..494ac6b5e 100644 --- a/server/src/uds/static/adm/js/gui-definition.js +++ b/server/src/uds/static/adm/js/gui-definition.js @@ -29,22 +29,60 @@ gui.dashboard.link = function(event) { gui.providers = new GuiElement(api.providers, 'provi'); gui.providers.link = function(event) { "use strict"; - gui.clearWorkspace(); - gui.appendToWorkspace(gui.breadcrumbs(gettext('Service Providers'))); - - var tableId = gui.providers.table({ - rowSelect : 'single', - onEdit: function(value, event, table) { - gui.providers.rest.gui(value.type, function(data) { - var form = gui.fields(data); - gui.appendToWorkspace(gui.modal('edit_modal', gettext('Edit service provider'), form)); - $('#edit_modal').modal() - .on('hidden.bs.modal', function () { - $('#edit_modal').remove(); + api.templates.get('providers', function(tmpl) { + gui.clearWorkspace(); + gui.appendToWorkspace(api.templates.evaluate(tmpl, { + providers : 'providers-placeholder', + services : 'services-placeholder', + })); + gui.setLinksEvents(); + + var tableId = gui.providers.table({ + container : 'providers-placeholder', + rowSelect : 'single', + onRowSelect : function(selected) { + api.tools.blockUI(); + gui.doLog(selected[0]); + var id = selected[0].id; + // Options for detail, to initialize types correctly + var detail_options = { + types: function(success_fnc, fail_fnc) { + success_fnc(selected[0].offers); + } + }; + // Giving the name compossed with type, will ensure that only styles will be reattached once + var services = new GuiElement(api.providers.detail(id, 'services', detail_options), 'services-'+selected[0].type); + + services.table({ + container : 'services-placeholder', + rowSelect : 'single', + buttons : [ 'new', 'edit', 'delete', 'xls' ], + scrollToTable : false, + onLoad: function(k) { + api.tools.unblockUI(); + }, + }); + return false; + }, + buttons : [ 'new', 'edit', 'delete', 'xls' ], + onEdit: function(value, event, table, refreshFnc) { + gui.providers.rest.gui(value.type, function(itemGui){ + gui.providers.rest.item(value.id, function(item) { + gui.forms.launchModal(gettext('Edit Service Provider')+' '+value.name, itemGui, item, function(form_selector, closeFnc) { + var fields = gui.forms.read(form_selector); + fields.data_type = value.type; + fields.nets_positive = false; + gui.providers.rest.save(fields, function(data) { // Success on put + closeFnc(); + refreshFnc(); + }, gui.failRequestModalFnc(gettext('Error creating Service Provider')) // Fail on put, show modal message + ); + return false; + }); }); - }); - }, - buttons : [ 'edit', 'delete', 'xls' ], + }); + }, + }); }); return false; @@ -157,9 +195,8 @@ gui.connectivity.link = function(event) { }, ] }; - var form = gui.form.fromFields(tabs, item); - gui.launchModalForm(gettext('Edit transport')+' '+value.name,form, function(form_selector, closeFnc) { - var fields = gui.form.read(form_selector); + gui.forms.launchModal(gettext('Edit transport')+' '+value.name, tabs, item, function(form_selector, closeFnc) { + var fields = gui.forms.read(form_selector); fields.data_type = value.type; fields.nets_positive = false; gui.connectivity.transports.rest.save(fields, function(data) { // Success on put @@ -182,12 +219,17 @@ gui.connectivity.link = function(event) { }, { title: 'Networks', - fields: [], + fields: [ + gui.forms.guiField('networks', 'multichoice', gettext('Available for networks'), + gettext('Select networks that will see this transport'), [], []), + gui.forms.guiField('nets_positive', 'checkbox', gettext('Transport active for selected networks'), + gettext('If active, transport will only be available on selected networks. If inactive, transport will be available form any net EXCEPT selected networks'), + true) + ], }, ] }; - var form = gui.form.fromFields(tabs); - gui.launchModalForm(gettext('New transport'), form, function(form_selector, closeFnc) { + gui.forms.launchModal(gettext('New transport'), tabs, undefined, function(form_selector, closeFnc) { var fields = gui.form.read(form_selector); // Append "own" fields, in this case data_type fields.data_type = type; diff --git a/server/src/uds/static/adm/js/gui-element.js b/server/src/uds/static/adm/js/gui-element.js index 6645b20d9..a591656a5 100644 --- a/server/src/uds/static/adm/js/gui-element.js +++ b/server/src/uds/static/adm/js/gui-element.js @@ -5,7 +5,7 @@ function BasicGuiElement(name) { this.name = name; } -function GuiElement(restItem, name) { +function GuiElement(restItem, name, typesFunction) { "use strict"; this.rest = restItem; this.name = name; @@ -22,6 +22,7 @@ GuiElement.prototype = { var self = this; this.rest.types(function(data) { var styles = ''; + var alreadyAttached = $('#gui-style-'+self.name).length !== 0; $.each(data, function(index, value) { var className = self.name + '-' + value.type; self.types[value.type] = { @@ -30,12 +31,15 @@ GuiElement.prototype = { description : value.description || '' }; gui.doLog('Creating style for ' + className); - var style = '.' + className + ' { display:inline-block; background: url(data:image/png;base64,' + - value.icon + '); ' + 'width: 16px; height: 16px; vertical-align: middle; } '; - styles += style; + if( !alreadyAttached ) { + var style = '.' + className + ' { display:inline-block; background: url(data:image/png;base64,' + + value.icon + '); ' + 'width: 16px; height: 16px; vertical-align: middle; } '; + styles += style; + } }); if (styles !== '') { - styles = ''; + // If style already attached, do not re-attach it + styles = ''; $(styles).appendTo('head'); } }); @@ -101,7 +105,8 @@ GuiElement.prototype = { // Icon renderer, based on type (created on init methods in styles) var renderTypeIcon = function(data, type, value){ if( type == 'display' ) { - var css = self.types[value.type].css; + self.types[value.type] = self.types[value.type] || {} + var css = self.types[value.type].css || 'fa fa-asterisk'; return ' ' + renderEmptyCell(data); } else { return renderEmptyCell(data); diff --git a/server/src/uds/static/adm/js/gui-form.js b/server/src/uds/static/adm/js/gui-form.js index d1d84c118..54bf11241 100644 --- a/server/src/uds/static/adm/js/gui-form.js +++ b/server/src/uds/static/adm/js/gui-form.js @@ -2,8 +2,10 @@ (function(gui, $, undefined) { "use strict"; - // Returns a form that will manage a gui description (new or edit) - gui.fieldsToHtml = function(itemGui, item, editing) { + gui.forms = {}; + + // Returns form fields that will manage a gui description (new or edit) + gui.forms.fieldsToHtml = function(itemGui, item, editing) { var html = ''; // itemGui is expected to have fields sorted by .gui.order (REST api returns them sorted) $.each(itemGui, function(index, f){ @@ -30,9 +32,7 @@ return html; }; - gui.form = {}; - - gui.form.fromFields = function(fields, item) { + gui.forms.fromFields = function(fields, item) { var editing = item !== undefined; // Locate real Editing item = item || {id:''}; var form = '
' + @@ -43,20 +43,20 @@ var tabsContent = []; var active = ' active in' ; $.each(fields.tabs, function(index, tab){ - tabsContent.push('
' + gui.fieldsToHtml(tab.fields, item) + '
' ); + tabsContent.push('
' + gui.forms.fieldsToHtml(tab.fields, item) + '
' ); tabs.push('
  • ' + tab.title + '
  • ' ); active = ''; }); form += '
    ' + tabsContent.join('\n') + '
    '; } else { - form += gui.fieldsToHtml(fields, item, editing); + form += gui.forms.fieldsToHtml(fields, item, editing); } form += '
    '; return form; }; // Reads fields from a form - gui.form.read = function(formSelector) { + gui.forms.read = function(formSelector) { var res = {}; $(formSelector + ' .modal_field_data').each(function(i, field) { var $field = $(field); @@ -72,6 +72,82 @@ return res; }; + gui.forms.launchModal = function(title, fields, item, onSuccess) { + var id = 'modal-' + Math.random().toString().split('.')[1]; // Get a random ID for this modal + gui.appendToWorkspace(gui.modal(id, title, gui.forms.fromFields(fields, item))); + id = '#' + id; // for jQuery + + // Get form + var $form = $(id + ' form'); + + // For "beauty" switches, initialize them now + $(id + ' .make-switch').bootstrapSwitch(); + // Activate "cool" selects + $(id + ' .selectpicker').selectpicker(); + // TEST: cooller on mobile devices + if( /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent) ) { + $(id + ' .selectpicker').selectpicker('mobile'); + } + // Activate tooltips + $(id + ' [data-toggle="tooltip"]').tooltip({delay: {show: 1000, hide: 100}, placement: 'auto right'}); + + // Validation + $form.validate({ + debug: true, + errorClass: 'text-danger', + validClass: 'has-success', + highlight: function(element) { + $(element).closest('.form-group').addClass('has-error'); + }, + success: function(element) { + $(element).closest('.form-group').removeClass('has-error'); + $(element).remove(); + }, + }); + + // And catch "accept" (default is "Save" in fact) button click + $(id + ' .button-accept').click(function(){ + if( !$form.valid() ) + return; + if( onSuccess ) { + onSuccess(id + ' form', function(){$(id).modal('hide');}); // Delegate close to to onSuccess + return; + } else { + $(id).modal('hide'); + } + + }); + + // Launch modal + $(id).modal({keyboard: false}) + .on('hidden.bs.modal', function () { + $(id).remove(); + }); + }; + + // simple gui generators + gui.forms.guiField = function(name, type, label, tooltip, value, values, length, multiline, readonly, required) { + length = length || 128; + multiline = multiline !== undefined ? multiline : 0; + readonly = readonly || false; + required = required || false; + return { + name: name, + gui: { + defvalue: value, + value: value, + values: values, + label: label, + length: length, + multiline: multiline, + rdonly: readonly, // rdonly applies just to editing + required: required, + tooltip: tooltip, + type: type, + } + }; + }; + }(window.gui = window.gui || {}, jQuery)); diff --git a/server/src/uds/static/adm/js/gui.js b/server/src/uds/static/adm/js/gui.js index fd313ab42..62e116a8a 100644 --- a/server/src/uds/static/adm/js/gui.js +++ b/server/src/uds/static/adm/js/gui.js @@ -118,58 +118,6 @@ }); }; - gui.launchModalForm = function(title, form, onSuccess) { - var id = 'modal-' + Math.random().toString().split('.')[1]; // Get a random ID for this modal - gui.appendToWorkspace(gui.modal(id, title, form)); - id = '#' + id; // for jQuery - - // Get form - var $form = $(id + ' form'); - - // For "beauty" switches, initialize them now - $(id + ' .make-switch').bootstrapSwitch(); - // Activate "cool" selects - $(id + ' .selectpicker').selectpicker(); - // TEST: cooller on mobile devices - if( /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent) ) { - $(id + ' .selectpicker').selectpicker('mobile'); - } - // Activate tooltips - $(id + ' [data-toggle="tooltip"]').tooltip({delay: {show: 1000, hide: 100}, placement: 'auto right'}); - - // Validation - $form.validate({ - debug: true, - errorClass: 'text-danger', - validClass: 'has-success', - highlight: function(element) { - $(element).closest('.form-group').addClass('has-error'); - }, - success: function(element) { - $(element).closest('.form-group').removeClass('has-error'); - $(element).remove(); - }, - }); - - // And catch "accept" (default is "Save" in fact) button click - $(id + ' .button-accept').click(function(){ - if( !$form.valid() ) - return; - if( onSuccess ) { - onSuccess(id + ' form', function(){$(id).modal('hide');}); // Delegate close to to onSuccess - return; - } else { - $(id).modal('hide'); - } - - }); - - // Launch modal - $(id).modal({keyboard: false}) - .on('hidden.bs.modal', function () { - $(id).remove(); - }); - }; gui.failRequestMessageFnc = function(jqXHR, textStatus, errorThrown) { api.templates.get('request_failed', function(tmpl) { diff --git a/server/src/uds/templates/uds/admin/tmpl/providers.html b/server/src/uds/templates/uds/admin/tmpl/providers.html new file mode 100644 index 000000000..cae5787b1 --- /dev/null +++ b/server/src/uds/templates/uds/admin/tmpl/providers.html @@ -0,0 +1,18 @@ +{% load i18n %} +
    +
    +

    {% trans 'Providers' %}

    + +
    +
    +{% verbatim %} +
    +
    +
    +
    +
    +
    +{% endverbatim %} \ No newline at end of file