Add yaml-editor to utils for editing yaml-files on the go
Add setup changes to install yaml-editor with spyglass
This commit is contained in:
parent
ecaef2b549
commit
12e25206f3
|
@ -0,0 +1,2 @@
|
|||
recursive-include spyglass/utils/editor/static *
|
||||
recursive-include spyglass/utils/editor/templates *
|
1
setup.py
1
setup.py
|
@ -36,6 +36,7 @@ setup(
|
|||
entry_points={
|
||||
'console_scripts': [
|
||||
'spyglass=spyglass.spyglass:main',
|
||||
'yaml-editor=spyglass.utils.editor.editor:main',
|
||||
],
|
||||
'data_extractor_plugins':
|
||||
['formation=spyglass.data_extractor.plugins.formation:FormationPlugin',
|
||||
|
|
|
@ -35,6 +35,7 @@ class NoSpecMatched(BaseError):
|
|||
self.specs))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
class MissingAttributeError(BaseError):
|
||||
pass
|
||||
|
||||
|
|
|
@ -19,8 +19,7 @@ import sys
|
|||
import yaml
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl import Workbook
|
||||
from spyglass.data_extractor.custom_exceptions import
|
||||
NoSpecMatched, )
|
||||
from spyglass.data_extractor.custom_exceptions import NoSpecMatched
|
||||
# from spyglass.data_extractor.custom_exceptions
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
|
|
@ -15,9 +15,12 @@
|
|||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pkg_resources
|
||||
import pprint
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import jsonschema
|
||||
import netaddr
|
||||
import yaml
|
||||
|
@ -81,6 +84,17 @@ class ProcessDataSource():
|
|||
LOG.debug("Genesis Node Details:\n{}".format(
|
||||
pprint.pformat(self.genesis_node)))
|
||||
|
||||
def _get_genesis_node_ip(self):
|
||||
""" Returns the genesis node ip """
|
||||
ip = '0.0.0.0'
|
||||
LOG.info("Getting Genesis Node IP")
|
||||
if not self.genesis_node:
|
||||
self._get_genesis_node_details()
|
||||
ips = self.genesis_node.get('ip', '')
|
||||
if ips:
|
||||
ip = ips.get('oam', '0.0.0.0')
|
||||
return ip
|
||||
|
||||
def _validate_intermediary_data(self, data):
|
||||
""" Validates the intermediary data before generating manifests.
|
||||
|
||||
|
@ -347,11 +361,27 @@ class ProcessDataSource():
|
|||
f.write(yaml_file)
|
||||
f.close()
|
||||
|
||||
def generate_intermediary_yaml(self):
|
||||
def generate_intermediary_yaml(self, edit_intermediary=False):
|
||||
""" Generating intermediary yaml """
|
||||
LOG.info("Start: Generate Intermediary")
|
||||
self._apply_design_rules()
|
||||
self._get_genesis_node_details()
|
||||
# This will validate the extracted data from different sources.
|
||||
self._validate_intermediary_data(self.data)
|
||||
if edit_intermediary:
|
||||
self.edit_intermediary_yaml()
|
||||
# This will check if user edited changes are in order.
|
||||
self._validate_intermediary_data(self.data)
|
||||
self.intermediary_yaml = self.data
|
||||
return self.intermediary_yaml
|
||||
|
||||
def edit_intermediary_yaml(self):
|
||||
""" Edit generated data using on browser """
|
||||
LOG.info(
|
||||
"edit_intermediary_yaml: Invoking web server for yaml editing")
|
||||
with tempfile.NamedTemporaryFile(mode='r+') as file_obj:
|
||||
yaml.safe_dump(self.data, file_obj, default_flow_style=False)
|
||||
host = self._get_genesis_node_ip()
|
||||
os.system('yaml-editor -f {0} -h {1}'.format(file_obj.name, host))
|
||||
file_obj.seek(0)
|
||||
self.data = yaml.safe_load(file_obj)
|
||||
|
|
|
@ -57,6 +57,11 @@ LOG = logging.getLogger('spyglass')
|
|||
'-idir',
|
||||
type=click.Path(exists=True),
|
||||
help='The path where intermediary file needs to be generated')
|
||||
@click.option(
|
||||
'--edit_intermediary/--no_edit_intermediary',
|
||||
'-e/-nedit',
|
||||
default=True,
|
||||
help='Flag to let user edit intermediary')
|
||||
@click.option(
|
||||
'--generate_manifests',
|
||||
'-m',
|
||||
|
@ -96,6 +101,7 @@ def main(*args, **kwargs):
|
|||
# Extract user provided inputs
|
||||
generate_intermediary = kwargs['generate_intermediary']
|
||||
intermediary_dir = kwargs['intermediary_dir']
|
||||
edit_intermediary = kwargs['edit_intermediary']
|
||||
generate_manifests = kwargs['generate_manifests']
|
||||
manifest_dir = kwargs['manifest_dir']
|
||||
intermediary = kwargs['intermediary']
|
||||
|
@ -176,7 +182,8 @@ def main(*args, **kwargs):
|
|||
data_extractor.site_data)
|
||||
|
||||
LOG.info("Generate intermediary yaml")
|
||||
intermediary_yaml = process_input_ob.generate_intermediary_yaml()
|
||||
intermediary_yaml = process_input_ob.generate_intermediary_yaml(
|
||||
edit_intermediary)
|
||||
else:
|
||||
LOG.info("Loading intermediary from user provided input")
|
||||
with open(intermediary, 'r') as intermediary_file:
|
||||
|
|
|
@ -0,0 +1,157 @@
|
|||
# Copyright 2018 AT&T Intellectual Property. All other rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
import click
|
||||
import yaml
|
||||
|
||||
from flask import Flask, request, render_template, send_from_directory
|
||||
from flask_bootstrap import Bootstrap
|
||||
|
||||
|
||||
app_path = os.path.dirname(os.path.abspath(__file__))
|
||||
app = Flask('Yaml Editor!',
|
||||
template_folder=os.path.join(app_path, 'templates'),
|
||||
static_folder=os.path.join(app_path, 'static'))
|
||||
Bootstrap(app)
|
||||
logging.getLogger('werkzeug').setLevel(logging.ERROR)
|
||||
LOG = app.logger
|
||||
|
||||
|
||||
@app.route('/favicon.ico')
|
||||
def favicon():
|
||||
return send_from_directory(app.static_folder, 'favicon.ico')
|
||||
|
||||
|
||||
@app.route('/', methods=['GET', 'POST'])
|
||||
def index():
|
||||
"""Renders index page to edit provided yaml file."""
|
||||
LOG.info('Rendering yaml file for editing')
|
||||
with open(app.config['YAML_FILE']) as file_obj:
|
||||
data = yaml.safe_load(file_obj)
|
||||
return render_template('yaml.html',
|
||||
data=json.dumps(data),
|
||||
change_str=app.config['STRING_TO_CHANGE'])
|
||||
|
||||
|
||||
@app.route('/save', methods=['POST'])
|
||||
def save():
|
||||
"""Save current progress on file."""
|
||||
LOG.info('Saving edited inputs from user to yaml file')
|
||||
out = request.json.get('yaml_data')
|
||||
with open(app.config['YAML_FILE'], 'w') as file_obj:
|
||||
yaml.safe_dump(out, file_obj, default_flow_style=False)
|
||||
return "Data saved successfully!"
|
||||
|
||||
|
||||
@app.route('/saveExit', methods=['POST'])
|
||||
def save_exit():
|
||||
"""Save current progress on file and shuts down the server."""
|
||||
LOG.info('Saving edited inputs from user to yaml file and shutting'
|
||||
' down server')
|
||||
out = request.json.get('yaml_data')
|
||||
with open(app.config['YAML_FILE'], 'w') as file_obj:
|
||||
yaml.safe_dump(out, file_obj, default_flow_style=False)
|
||||
func = request.environ.get('werkzeug.server.shutdown')
|
||||
if func:
|
||||
func()
|
||||
return "Saved successfully, Shutting down app! You may close the tab!"
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
def page_not_found(e):
|
||||
"""Serves 404 error."""
|
||||
LOG.info('User tried to access unavailable page.')
|
||||
return '<h1>404: Page not Found!</h1>'
|
||||
|
||||
|
||||
def run(*args, **kwargs):
|
||||
"""Starts the server."""
|
||||
LOG.info('Initiating web server for yaml editing')
|
||||
port = kwargs.get('port', None)
|
||||
if not port:
|
||||
port = 8161
|
||||
app.run(host='0.0.0.0', port=port, debug=False)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
'--file',
|
||||
'-f',
|
||||
required=True,
|
||||
type=click.File(),
|
||||
multiple=False,
|
||||
help="Path with file name to the intermediary yaml file."
|
||||
)
|
||||
@click.option(
|
||||
'--host',
|
||||
'-h',
|
||||
default='0.0.0.0',
|
||||
type=click.STRING,
|
||||
multiple=False,
|
||||
help="Optional port parameter to run Flask on."
|
||||
)
|
||||
@click.option(
|
||||
'--port',
|
||||
'-p',
|
||||
default=8161,
|
||||
type=click.INT,
|
||||
multiple=False,
|
||||
help="Optional port parameter to run Flask on."
|
||||
)
|
||||
@click.option(
|
||||
'--string',
|
||||
'-s',
|
||||
default='#CHANGE_ME',
|
||||
type=click.STRING,
|
||||
multiple=False,
|
||||
help="Text which is required to be changed on yaml file."
|
||||
)
|
||||
def main(*args, **kwargs):
|
||||
LOG.setLevel(logging.INFO)
|
||||
LOG.info('Initiating yaml-editor')
|
||||
try:
|
||||
yaml.safe_load(kwargs['file'])
|
||||
except yaml.YAMLError as e:
|
||||
LOG.error('EXITTING - Please provide a valid yaml file.')
|
||||
if hasattr(e, 'problem_mark'):
|
||||
mark = e.problem_mark
|
||||
LOG.error("Error position: ({0}:{1})".format(
|
||||
mark.line + 1, mark.column + 1))
|
||||
sys.exit(2)
|
||||
except Exception:
|
||||
LOG.error('EXITTING - Please provide a valid yaml file.')
|
||||
sys.exit(2)
|
||||
LOG.info("""
|
||||
|
||||
##############################################################################
|
||||
|
||||
Please go to http://{0}:{1}/ to edit your yaml file.
|
||||
|
||||
##############################################################################
|
||||
|
||||
""".format(kwargs['host'], kwargs['port']))
|
||||
app.config['YAML_FILE'] = kwargs['file'].name
|
||||
app.config['STRING_TO_CHANGE'] = kwargs['string']
|
||||
run(*args, **kwargs)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
"""Invoked when used as a script."""
|
||||
main()
|
|
@ -0,0 +1,92 @@
|
|||
// Copyright 2018 AT&T Intellectual Property. All other rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
|
||||
// This file includes all the frond-end functionality being used for the
|
||||
// yaml editor application.
|
||||
|
||||
|
||||
/**
|
||||
* Calls /save URL to save edit progress.
|
||||
* @param {String} data Stringified JSON data.
|
||||
*/
|
||||
function save(data) {
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: '/save',
|
||||
data: data,
|
||||
success: function(res) {
|
||||
setTimeout(function() { alert(res); }, 3);
|
||||
},
|
||||
contentType: 'application/json;charset=UTF-8'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls /saveExit URL to save edit progress and shut down web server.
|
||||
* @param {String} data Stringified JSON data.
|
||||
*/
|
||||
function saveAndExit(data) {
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: '/saveExit',
|
||||
data: data,
|
||||
success: function(res) {
|
||||
setTimeout(function() { alert(res); }, 3);
|
||||
},
|
||||
contentType: 'application/json;charset=UTF-8'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects and validates data from textarea.
|
||||
* @returns {String} Stringified JSON data.
|
||||
*/
|
||||
function getSimpleData() {
|
||||
var data = $("#yaml_data").val();
|
||||
try {
|
||||
var index = data.indexOf(changeStr)
|
||||
if (index != -1) {
|
||||
var lineNum = data.substring(0, index).split('\n').length;
|
||||
alert('Please change value on line '+ lineNum + '!')
|
||||
return null
|
||||
}
|
||||
data = jsyaml.load(data)
|
||||
}
|
||||
catch(err) {
|
||||
alert(err)
|
||||
return null
|
||||
}
|
||||
return JSON.stringify({yaml_data : data})
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to save edit progress.
|
||||
*/
|
||||
function saveSimple() {
|
||||
var data = getSimpleData()
|
||||
if (data) {
|
||||
save(data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to save edit progress and shut down web server.
|
||||
*/
|
||||
function saveExitSimple() {
|
||||
var data = getSimpleData()
|
||||
if (data) {
|
||||
saveAndExit(data)
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* jQuery Lined Textarea Plugin
|
||||
* http://alan.blog-city.com/jquerylinedtextarea.htm
|
||||
*
|
||||
* Copyright (c) 2010 Alan Williamson
|
||||
*
|
||||
* Released under the MIT License:
|
||||
* http://www.opensource.org/licenses/mit-license.php
|
||||
*
|
||||
* Usage:
|
||||
* Displays a line number count column to the left of the textarea
|
||||
*
|
||||
* Class up your textarea with a given class, or target it directly
|
||||
* with JQuery Selectors
|
||||
*
|
||||
* $(".lined").linedtextarea({
|
||||
* selectedLine: 10,
|
||||
* selectedClass: 'lineselect'
|
||||
* });
|
||||
*
|
||||
*/
|
||||
|
||||
.linedwrap {
|
||||
border: 1px solid #c0c0c0;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.linedtextarea {
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.linedtextarea textarea, .linedwrap .codelines .lineno {
|
||||
font-size: 10pt;
|
||||
font-family: monospace;
|
||||
line-height: normal !important;
|
||||
}
|
||||
|
||||
.linedtextarea textarea {
|
||||
padding-right:0.3em;
|
||||
padding-top:0.3em;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.linedwrap .lines {
|
||||
margin-top: 0px;
|
||||
width: 50px;
|
||||
float: left;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid #c0c0c0;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.linedwrap .codelines {
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.linedwrap .codelines .lineno {
|
||||
color:#AAAAAA;
|
||||
padding-right: 0.5em;
|
||||
padding-top: 0.0em;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.linedwrap .codelines .lineselect {
|
||||
color: red;
|
||||
}
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* jQuery Lined Textarea Plugin
|
||||
* http://alan.blog-city.com/jquerylinedtextarea.htm
|
||||
*
|
||||
* Copyright (c) 2010 Alan Williamson
|
||||
*
|
||||
* Version:
|
||||
* $Id: jquery-linedtextarea.js 464 2010-01-08 10:36:33Z alan $
|
||||
*
|
||||
* Released under the MIT License:
|
||||
* http://www.opensource.org/licenses/mit-license.php
|
||||
*
|
||||
* Usage:
|
||||
* Displays a line number count column to the left of the textarea
|
||||
*
|
||||
* Class up your textarea with a given class, or target it directly
|
||||
* with JQuery Selectors
|
||||
*
|
||||
* $(".lined").linedtextarea({
|
||||
* selectedLine: 10,
|
||||
* selectedClass: 'lineselect'
|
||||
* });
|
||||
*
|
||||
* History:
|
||||
* - 2010.01.08: Fixed a Google Chrome layout problem
|
||||
* - 2010.01.07: Refactored code for speed/readability; Fixed horizontal sizing
|
||||
* - 2010.01.06: Initial Release
|
||||
*
|
||||
*/
|
||||
(function($) {
|
||||
|
||||
$.fn.linedtextarea = function(options) {
|
||||
|
||||
// Get the Options
|
||||
var opts = $.extend({}, $.fn.linedtextarea.defaults, options);
|
||||
|
||||
|
||||
/*
|
||||
* Helper function to make sure the line numbers are always
|
||||
* kept up to the current system
|
||||
*/
|
||||
var fillOutLines = function(codeLines, h, lineNo){
|
||||
while ( (codeLines.height() - h ) <= 0 ){
|
||||
if ( lineNo == opts.selectedLine )
|
||||
codeLines.append("<div class='lineno lineselect'>" + lineNo + "</div>");
|
||||
else
|
||||
codeLines.append("<div class='lineno'>" + lineNo + "</div>");
|
||||
|
||||
lineNo++;
|
||||
}
|
||||
return lineNo;
|
||||
};
|
||||
|
||||
|
||||
/*
|
||||
* Iterate through each of the elements are to be applied to
|
||||
*/
|
||||
return this.each(function() {
|
||||
var lineNo = 1;
|
||||
var textarea = $(this);
|
||||
|
||||
/* Turn off the wrapping of as we don't want to screw up the line numbers */
|
||||
textarea.attr("wrap", "off");
|
||||
textarea.css({resize:'none'});
|
||||
var originalTextAreaWidth = textarea.outerWidth();
|
||||
|
||||
/* Wrap the text area in the elements we need */
|
||||
textarea.wrap("<div class='linedtextarea'></div>");
|
||||
var linedTextAreaDiv = textarea.parent().wrap("<div class='linedwrap' style='width:" + originalTextAreaWidth + "px'></div>");
|
||||
var linedWrapDiv = linedTextAreaDiv.parent();
|
||||
|
||||
linedWrapDiv.prepend("<div class='lines' style='width:50px'></div>");
|
||||
|
||||
var linesDiv = linedWrapDiv.find(".lines");
|
||||
linesDiv.height( textarea.height() + 6 );
|
||||
|
||||
|
||||
/* Draw the number bar; filling it out where necessary */
|
||||
linesDiv.append( "<div class='codelines'></div>" );
|
||||
var codeLinesDiv = linesDiv.find(".codelines");
|
||||
lineNo = fillOutLines( codeLinesDiv, linesDiv.height(), 1 );
|
||||
|
||||
/* Move the textarea to the selected line */
|
||||
if ( opts.selectedLine != -1 && !isNaN(opts.selectedLine) ){
|
||||
var fontSize = parseInt( textarea.height() / (lineNo-2) );
|
||||
var position = parseInt( fontSize * opts.selectedLine ) - (textarea.height()/2);
|
||||
textarea[0].scrollTop = position;
|
||||
}
|
||||
|
||||
|
||||
/* Set the width */
|
||||
var sidebarWidth = linesDiv.outerWidth();
|
||||
var paddingHorizontal = parseInt( linedWrapDiv.css("border-left-width") ) + parseInt( linedWrapDiv.css("border-right-width") ) + parseInt( linedWrapDiv.css("padding-left") ) + parseInt( linedWrapDiv.css("padding-right") );
|
||||
var linedWrapDivNewWidth = originalTextAreaWidth - paddingHorizontal;
|
||||
var textareaNewWidth = originalTextAreaWidth - sidebarWidth - paddingHorizontal - 20;
|
||||
|
||||
textarea.width( textareaNewWidth );
|
||||
linedWrapDiv.width( linedWrapDivNewWidth );
|
||||
|
||||
|
||||
|
||||
/* React to the scroll event */
|
||||
textarea.scroll( function(tn){
|
||||
var domTextArea = $(this)[0];
|
||||
var scrollTop = domTextArea.scrollTop;
|
||||
var clientHeight = domTextArea.clientHeight;
|
||||
codeLinesDiv.css( {'margin-top': (-1*scrollTop) + "px"} );
|
||||
lineNo = fillOutLines( codeLinesDiv, scrollTop + clientHeight, lineNo );
|
||||
});
|
||||
|
||||
|
||||
/* Should the textarea get resized outside of our control */
|
||||
textarea.resize( function(tn){
|
||||
var domTextArea = $(this)[0];
|
||||
linesDiv.height( domTextArea.clientHeight + 6 );
|
||||
});
|
||||
|
||||
});
|
||||
};
|
||||
|
||||
// default options
|
||||
$.fn.linedtextarea.defaults = {
|
||||
selectedLine: -1,
|
||||
selectedClass: 'lineselect'
|
||||
};
|
||||
})(jQuery);
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,38 @@
|
|||
{% extends "bootstrap/base.html" %}
|
||||
{% block title %}YAML Editor{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
{{super()}}
|
||||
<link href="{{ url_for('static', filename='jquery-linedtextarea.css') }}" rel="stylesheet">
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{super()}}
|
||||
<script src="{{ url_for('static', filename='js-yaml.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='jquery-linedtextarea.js') }}"></script>
|
||||
<script type="text/javascript">
|
||||
var changeStr = '{{ change_str }}'
|
||||
$(document).ready(function(){
|
||||
$("#yaml_data").val(jsyaml.dump(JSON.parse('{{ data|safe }}')))
|
||||
$("#yaml_data").linedtextarea();
|
||||
});
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="container" style="margin-top:30px;">
|
||||
<div class="form-group">
|
||||
<pre>Edit your YAML (Update corresponding fields with {{ change_str }} text):</pre>
|
||||
<div>
|
||||
<textarea class="form-control linedtextarea" id='yaml_data' style="height: 500px;box-shadow: none;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group pull-right">
|
||||
<button type="button" onclick="saveSimple()" class="btn btn-lg btn-success ">Save</button>
|
||||
<button type="button" onclick="saveExitSimple()" class="btn btn-lg btn-primary ">Save and Exit</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
Loading…
Reference in New Issue