You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
757 lines
25 KiB
757 lines
25 KiB
<!doctype html> |
|
<html lang="en"> |
|
|
|
<head> |
|
<meta charset="utf-8"> |
|
|
|
<title>Flask</title> |
|
|
|
<meta name="author" content="Julio Biason"> |
|
|
|
<meta name="apple-mobile-web-app-capable" content="yes" /> |
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> |
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> |
|
|
|
<link rel="stylesheet" href="_external/reveal.min.css"> |
|
<link rel="stylesheet" href="_external/default.css" id="theme"> |
|
|
|
<!-- For syntax highlighting --> |
|
<link rel="stylesheet" href="_external/zenburn.css"> |
|
|
|
<!-- If the query includes 'print-pdf', include the PDF print sheet --> |
|
<script> |
|
if( window.location.search.match( /print-pdf/gi ) ) { |
|
var link = document.createElement( 'link' ); |
|
link.rel = 'stylesheet'; |
|
link.type = 'text/css'; |
|
link.href = '_external/pdf.css'; |
|
document.getElementsByTagName( 'head' )[0].appendChild( link ); |
|
} |
|
</script> |
|
|
|
<!--[if lt IE 9]> |
|
<script src="reveal.js/lib/js/html5shiv.js"></script> |
|
<![endif]--> |
|
|
|
<style> |
|
.semi-opaque { |
|
background-color: rgba(0, 0, 0, 0.7); |
|
} |
|
|
|
* { |
|
hyphens: none !important; |
|
-moz-hyphens: none !important; |
|
} |
|
</style> |
|
</head> |
|
|
|
<body> |
|
|
|
<div class="reveal"> |
|
<!-- Any section element inside of this container is displayed as a slide --> |
|
<div class="slides"> |
|
<section data-background='_images/flask.png'> |
|
<h1>Flask</h1> |
|
</section> |
|
|
|
<section> |
|
<h2>O que é Flask?</h2> |
|
|
|
<ul> |
|
<li>Microframework web em Python.</li> |
|
<li>Framework sobre o Werkzeug (outro framework).</li> |
|
<li>Sem ORM, mas templates.</li> |
|
</ul> |
|
</section> |
|
|
|
<section> |
|
<section> |
|
<h2>Aplicativo Flask Básico</h2> |
|
|
|
<p><pre><code data-trim> |
|
from flask import Flask |
|
app = Flask(__name__) |
|
|
|
@app.route('/') |
|
def index(): |
|
return 'Hello world' |
|
</code></pre></p> |
|
</section> |
|
|
|
<section> |
|
<p><pre><code data-trim> |
|
#!/usr/bin/env python |
|
# -*- encoding: utf-8 -*- |
|
|
|
from flask import Flask |
|
app = Flask(__name__) |
|
|
|
@app.route('/') |
|
def index(): |
|
return 'Hello world' |
|
</code></pre></p> |
|
<p><small>... mais o header...</small></p> |
|
</section> |
|
|
|
<section> |
|
<p><pre><code data-trim> |
|
#!/usr/bin/env python |
|
# -*- encoding: utf-8 -*- |
|
|
|
"""Meu aplicativo web em Flask.""" |
|
|
|
from flask import Flask |
|
app = Flask(__name__) |
|
|
|
@app.route('/') |
|
def index(): |
|
return 'Hello world' |
|
</code></pre></p> |
|
<p><small>... mais a documentação do módulo...</small></p> |
|
</section> |
|
|
|
<section> |
|
<p><pre><code data-trim> |
|
#!/usr/bin/env python |
|
# -*- encoding: utf-8 -*- |
|
|
|
"""Meu aplicativo web em Flask.""" |
|
|
|
from flask import Flask |
|
app = Flask(__name__) |
|
|
|
@app.route('/') |
|
def index(): |
|
"""Apresentação do 'root' do aplicativo.""" |
|
return 'Hello world' |
|
</code></pre></p> |
|
|
|
<p><small>... mais a documentação das funções...</small></p> |
|
</section> |
|
|
|
<section> |
|
<p><pre><code data-trim> |
|
#!/usr/bin/env python |
|
# -*- encoding: utf-8 -*- |
|
|
|
"""Meu aplicativo web em Flask.""" |
|
|
|
from flask import Flask |
|
from flask import render_template |
|
|
|
app = Flask(__name__) |
|
|
|
@app.route('/') |
|
def index(): |
|
"""Apresentação do 'root' do aplicativo.""" |
|
return render_template('hello.html') |
|
</code></pre></p> |
|
|
|
<p><small>... mais retornar templates ao invés de texto puro...</small></p> |
|
</section> |
|
|
|
<section> |
|
<p><pre><code data-trim> |
|
#!/usr/bin/env python |
|
# -*- encoding: utf-8 -*- |
|
|
|
"""Meu aplicativo web em Flask.""" |
|
|
|
class Settings: |
|
SECRET_KEY = 'Sup3rs3cr33t' |
|
|
|
from flask import Flask |
|
from flask import render_template |
|
|
|
app = Flask(__name__) |
|
app.config.from_object(Settings) |
|
app.config.from_envvar('MEU_APLICATIVO_CONFIG') |
|
|
|
|
|
@app.route('/') |
|
def index(): |
|
"""Apresentação do 'root' do aplicativo.""" |
|
return render_template('hello.html') |
|
</code></pre></p> |
|
|
|
<p><small>... mais adicionar uma configuração...</small></p> |
|
</section> |
|
|
|
<section> |
|
<p><small>... mais tratamento de erros...</small></p> |
|
|
|
<p class='fragment'><small>... mais outras rotas...</small></p> |
|
|
|
<p class='fragment'><small>... mais blueprints/applications...</small></p> |
|
|
|
<p class='fragment'><small>... mais inicialização do ORM...</small></p> |
|
</section> |
|
</section> |
|
|
|
<section> |
|
<img src='_images/baby-steps.jpg'> |
|
</section> |
|
|
|
<section> |
|
<h2>Criando uma app</h2> |
|
|
|
<p><pre><code data-trim> |
|
app = Flask(__name__) |
|
</code></pre></p> |
|
|
|
<p><code>__name__</code> é usado para que, internamente, os módulos/imports sejam encontrados.</p> |
|
|
|
<p>Se o app estiver em "meuservico/app.py", as duas linhas abaixo funcionam de forma idêntica:<br/> |
|
<pre><code>app = Flask(__name__)</code></pre> |
|
<pre><code>app = Flask("meuservico")</code></pre> |
|
</p> |
|
</section> |
|
|
|
<section> |
|
<section> |
|
<h2>Contextos</h2> |
|
</section> |
|
|
|
<section> |
|
<p>Essa é a parte chata do Flask.</p> |
|
|
|
<p>Existem dois contextos: Contexto de aplicação e Contexto que requisição.</p> |
|
</section> |
|
|
|
<section> |
|
<p>Contexto de aplicação só existe quando o app está rodando.</p> |
|
|
|
<p>Acessado com <code>current_app</code>.</p> |
|
|
|
<p><pre><code data-trim> |
|
from flask import current_app |
|
</code></pre></p> |
|
|
|
<p>É a única forma de acessar dados da aplicação enquanto ela |
|
está rodando.</p> |
|
</section> |
|
|
|
<section> |
|
<p>Contexto de requisição só existe quando o sistema está |
|
atendendo uma requisção (recebeu uma URL).</p> |
|
|
|
<p>Acessado com <code>request</code>.</p> |
|
|
|
<p><pre><code data-trim> |
|
from flask import request |
|
</code></pre></p> |
|
|
|
<p>Mais sobre <code>request</code> mais adiante.</p> |
|
</section> |
|
</section> |
|
<section> |
|
<p>Para acessar as configurações, usa-se a propriedade <code>config</code> |
|
da aplicação quando esta está rodando.</p> |
|
|
|
<p>(Contexto de aplicação, lembra?)</p> |
|
|
|
<p><pre><code data-trim> |
|
from flask import current_app |
|
from flask import render_template |
|
|
|
@app.route('/') |
|
def index(): |
|
return render_template('template.html', |
|
order=current_app.config.get('ORDER_FIELD')) |
|
</code></pre></p> |
|
</section> |
|
</section> |
|
|
|
<section> |
|
<section> |
|
<h2>Rotas</h2> |
|
</section> |
|
|
|
<section> |
|
<p>Rotas são definidas com o decorator <code>@[app].route([rota])</code>. Por exemplo:</p> |
|
|
|
<p><pre><code data-trim> |
|
app = Flask(__name__) |
|
@app.route('/') |
|
def index(): |
|
return 'Olá mundo' |
|
</code></pre></p> |
|
</section> |
|
|
|
<section> |
|
<p>Rotas também podem definir quais métodos HTTP são aceitos:</p> |
|
<p><pre><code data-trim> |
|
@app.route('/', methods=['POST', 'GET']) |
|
def index(): |
|
return 'Olá mundo' |
|
</code></pre></p> |
|
|
|
<p>"<code>PUT /</code>" irá retornar status 405: Method Not Allowed.</p> |
|
</section> |
|
|
|
<section> |
|
<p>Rotas podem ser repetidas, desde que os métodos não colidam:</p> |
|
<p><pre><code data-trim> |
|
@app.route('/', methods=['GET']) |
|
def list(): |
|
return 'Olá mundo' |
|
|
|
|
|
@app.route('/', methods=['POST']) |
|
def update(): |
|
return 'O seu mundo foi atualizado' |
|
</code></pre></p> |
|
</section> |
|
|
|
<section> |
|
<p>Rotas podem ter parâmetros:</p> |
|
<p><pre><code data-trim> |
|
app = Flask(__name__) |
|
@app.route('/<usuario>/data') |
|
def index(usuario): |
|
return 'Olá {nome}'.format(nome=usuario) |
|
</code></pre></p> |
|
</section> |
|
|
|
<section> |
|
<p>Parâmetros podem ter um tipo definido:</p> |
|
|
|
<p><pre><code data-trim> |
|
@app.route('/add/<int:var1>/<int:var2>') |
|
def add(var1, var2): |
|
return 'Soma = {sum}'.format(sum=var1+var2) |
|
</code></pre></p> |
|
</section> |
|
|
|
<section> |
|
<p>Problemas: Número de rotas tente a crescer. E como passar <code>app</code> para cima e para baixo?</p> |
|
</section> |
|
</section> |
|
|
|
<section> |
|
<section> |
|
<h2>Blueprints</h2> |
|
</section> |
|
|
|
<section> |
|
<p>Blueprints são funções relacionadas (por exemplo, pelo recurso base) |
|
que podem ser desenvolvidos separados do módulo principal e entre si.</p> |
|
|
|
<p>(Conceito semelhante aos "apps" do Django ou "scaffolding" do Pyramid/Pylons.)</p> |
|
</section> |
|
|
|
<section> |
|
<h3>Esqueleto de um Blueprint</h3> |
|
|
|
<p><pre><code> |
|
from flask import Blueprint |
|
|
|
blueprint_exemplo = Blueprint('exemplo', __name__) |
|
|
|
@blueprint_exemplo.route('/say/<usuario>') |
|
def blueprint_index(usuario): |
|
return 'Olá {nome}'.format(nome=usuario) |
|
</code></pre></p> |
|
</section> |
|
|
|
<section> |
|
<h3>E para ativar o Blueprint...</h3> |
|
|
|
<p><pre><code> |
|
app = Flask(__name__) |
|
|
|
from exemplo import blueprint_exemplo |
|
app.register(blueprint_exemplo, url_prefix='/exemplo') |
|
</code></pre></p> |
|
|
|
<p>A combinação do blueprint anterior com esse carregamento, gera a URL |
|
<code>/exemplo/say/<usuario></code>.</p> |
|
</section> |
|
|
|
<section> |
|
<p><pre><code> |
|
blueprint_exemplo = Blueprint('exemplo', __name__) |
|
</code></pre></p> |
|
|
|
<ul> |
|
<li><code>exemplo</code> é o nome do blueprint (para que isso serve, mais adiante)</li> |
|
<li><code>__name__</code> tem a mesma finalidade do <code>__name__</code> do app: encontrar módulos/recursos.</li> |
|
</ul> |
|
</section> |
|
|
|
<section> |
|
<p>Tudo que foi visto sobre rotas continua valendo:</p> |
|
|
|
<p><pre><code data-trim> |
|
@blueprint_exemplo.route('/<int:var1>/<int:var2>', methods=['GET']) |
|
def sum(var1, var2): |
|
return 'Soma = {sum}'.format(sum=var1+var2) |
|
</code></pre></p> |
|
</section> |
|
</section> |
|
|
|
<section> |
|
<section> |
|
<h2>Templates</h2> |
|
</section> |
|
|
|
<section> |
|
<p>Flask vem com Jinja2 como renderizado de templates.</p> |
|
|
|
<p>A sintaxe é muito semelhante ao sistema de templates do Django:</p> |
|
|
|
<p><pre><code data-trim> |
|
{% for nome in usuarios %} |
|
<div class='usuario'>Olá {{ nome }}<div> |
|
{% endfor %} |
|
</code></pre></p> |
|
</section> |
|
|
|
<section> |
|
<p>Para usar um template, basta usar a função <code>render_template</code>.</p> |
|
|
|
<p>Parâmetros para o template devem ser passados como parâmetros adicionais na função:</p> |
|
|
|
<p><pre><code data-trim> |
|
from flask import render_template |
|
|
|
@app.index('/') |
|
def index(): |
|
return render_template('hello-world.html', |
|
usuarios=['Julio', 'Leandro', 'Bruna', 'Cláudio']) |
|
</code></pre></p> |
|
</section> |
|
</section> |
|
|
|
<section> |
|
<section> |
|
<h2>Responses</h2> |
|
</section> |
|
|
|
<section> |
|
<p><code>render_template()</code> é simplesmente um parser de templates com um gerador |
|
de Responses.</p> |
|
|
|
<p class='fragment'>(Ou seja, o resultado esperado das funções -- qualquer função -- é um Response.)</p> |
|
|
|
<p class='fragment'>(Criar um response especializado é algo que somente e feito em 2% dos casos.)</p> |
|
</section> |
|
|
|
<section> |
|
<p>Por que isso é importante?</p> |
|
|
|
<p class='fragment'>Responses tem algumas propriedades a mais, como o tipo do retorno (text/html, por exemplo) |
|
e o status.</p> |
|
</section> |
|
|
|
<section> |
|
<p>Para retornar um status diferente de 200:</p> |
|
|
|
<p><pre><code data-trim> |
|
def index(): |
|
resp = render_template('404-not-found.html') |
|
resp.status_code = 404 |
|
return resp |
|
</code></pre></p> |
|
</section> |
|
|
|
<section> |
|
<p>Para retornar um tipo diferente, é preciso criar o Response do zero:</p> |
|
|
|
<p><pre><code data-trim> |
|
def index(): |
|
resp = make_response('Olá mundo') |
|
resp.status_code = 200 |
|
resp.mimetype = 'text/plain' |
|
</code></pre></p> |
|
</section> |
|
|
|
<section> |
|
<p>Para JSON, já existe uma função pronta:</p> |
|
|
|
<p><pre><code data-trim> |
|
from flask import jsonify |
|
|
|
@app.route('/') |
|
def index(): |
|
usuarios=['Julio', 'Leandro', 'Bruna', 'Cláudio'] |
|
return jsonify(status='OK', |
|
usuarios=usuarios) |
|
</code></pre></p> |
|
|
|
<p>Isso gera o JSON</p> |
|
<p><code>{status: "OK", usuarios=["Julio", "Leandro", "Bruna", "Claudio"]}</code>.</p> |
|
</section> |
|
|
|
<section> |
|
<p>Como <code>jsonify()</code> gera um Response, ele ainda pode ser mexido:</p> |
|
|
|
<p><pre><code data-trim> |
|
def index(): |
|
resp = jsonify(status='ERROR', code='404') |
|
resp.status = 404 |
|
return resp |
|
</code></pre></p> |
|
</section> |
|
|
|
<section> |
|
<p>Flask também tem funções para auxiliar na geração de respostas que não são "páginas":</p> |
|
|
|
<p><pre><code data-trim> |
|
from flask import abort |
|
|
|
def index(): |
|
abort(404) |
|
</code></pre></p> |
|
|
|
<p>Gera um response com status 404 padrão do sistema.</p> |
|
|
|
<p><pre><code data-trim> |
|
from flask import redirect |
|
|
|
def index(): |
|
redirect('/correct-path') |
|
</code></pre></p> |
|
|
|
<p>Gera um redirectionamento para <code>/correct-path</code>.</p> |
|
</section> |
|
</section> |
|
|
|
<section> |
|
<section> |
|
<h2>Requests</h2> |
|
</section> |
|
|
|
<section> |
|
<p>Request contém as informações que estão vindo na requisição:</p> |
|
|
|
<ul> |
|
<li><code>request.method</code>: Método HTTP utilizado.</li> |
|
<li><code>request.form</code>: Dados do formulário com POST/PUT.</li> |
|
<li><code>request.args</code>: Dados do querystring (GET).</li> |
|
<li><code>request.values</code>: form + args.</li> |
|
<li><code>request.cookies</code>: Cookies da página.</li> |
|
<li><code>request.headers</code>: Headers recebidos.</li> |
|
<li><code>request.files</code>: Arquivos enviados.</li> |
|
<li><code>request.get_json()</code>: Parseia a resposta se ela for JSON.</li> |
|
</ul> |
|
</section> |
|
|
|
<section> |
|
<p><pre><code data-trim> |
|
from flask import request # acesso ao objeto de request atual |
|
from flask import abort |
|
from flask import render_template |
|
|
|
def index(): |
|
nome = request.values.get('usuario') |
|
if not nome: |
|
abort(400) # Bad Request |
|
|
|
return render_template('hello-world.html', |
|
usuarios=[nome]) |
|
</code></pre></p> |
|
</section> |
|
</section> |
|
|
|
<section> |
|
<section> |
|
<h2>Tratamento de erros</h2> |
|
</section> |
|
|
|
<section> |
|
<p>Para mostrar páginas diferentes da default, basta adicionar um <code>errorhandler</code>.</p> |
|
|
|
<p><pre><code data-trim> |
|
app = Flask(__name__) |
|
|
|
@app.errorhandler(404) |
|
def not_found(): |
|
return jsonify(status='ERROR', |
|
message='Not found') |
|
</code></pre></p> |
|
</section> |
|
|
|
<section> |
|
<p><code>errorhandler</code> também pode capturar excessões:</p> |
|
|
|
<p><pre><code data-trim> |
|
from flask import Flask |
|
from mongoengine import NotFoundError |
|
|
|
app = Flask(__name__) |
|
|
|
@app.errorhandler(NotFoundError) |
|
def not_found(): |
|
return jsonify(status='ERROR', |
|
message='Mongo object not found') |
|
</code></pre></p> |
|
|
|
<p>Isso captura qualquer ocorrência de <code>NotFoundError</code>, inclusive |
|
dentro dos blueprints.</p> |
|
</section> |
|
</section> |
|
|
|
<section> |
|
<section> |
|
<h2>Configurações</h2> |
|
</section> |
|
|
|
<section> |
|
<p>Configurações podem vir de 3 lugares diferentes:</p> |
|
|
|
<ul> |
|
<li>De uma classe.</li> |
|
<li>De um arquivo Python.</li> |
|
<li>De um arquivo apontando por uma variável de ambiente.</li> |
|
</ul> |
|
|
|
<p>Todos os três podem ser executados em sequência, o último valor |
|
encontrado é o que vale.</p> |
|
</p> |
|
</section> |
|
|
|
<section> |
|
<p><pre><code data-trim> |
|
class Settings(objects): |
|
FILE_PATH = './here' |
|
ORDER_FIELD = 'name' |
|
</code></pre></p> |
|
|
|
<p><pre><code data-trim> |
|
app = Flask(__name__) |
|
|
|
app.config.from_object(Settings) |
|
app.config.from_pyfile('/etc/meuaplicativo.cfg') |
|
app.config.from.envvar('MEUAPLICATIVO_CFG') |
|
</code></pre></p> |
|
</section> |
|
</section> |
|
|
|
<section> |
|
<section> |
|
<h2>URLs reversas/Endpoints</h2> |
|
</section> |
|
|
|
<section> |
|
<p>Se eu posso registrar um Blueprint com qualquer prefixo, como eu descubro depois qual a URL |
|
de um recurso (se eu precisar fazer um redirect)?</p> |
|
|
|
<p class='fragment'><code>url_for()</code></p> |
|
</section> |
|
|
|
<section> |
|
<p><code>url_for()</code> recebe um endpoint e retorna a URL para aquele endpoint.</p> |
|
|
|
<p class='fragment'>O que diabos é um endpoint?</p> |
|
</section> |
|
|
|
<section> |
|
<p><pre><code data-trim> |
|
@app.route('/') |
|
def index(): |
|
return 'Olá mundo' |
|
</code></pre></p> |
|
|
|
<p>Endpoint = <code>index</code></p> |
|
</section> |
|
|
|
<section> |
|
<p><pre><code data-trim> |
|
exemplo = Blueprint('meuexemplo', __name__) |
|
|
|
@exemplo.route('/') |
|
def index(): |
|
return 'Olá mundo' |
|
</code></pre></p> |
|
|
|
<p>Endpoint = <code class='fragment'>meuexemplo.index</code></p> |
|
</section> |
|
|
|
<section> |
|
<p><pre><code data-trim> |
|
exemplo = Blueprint('meuexemplo', __name__) |
|
|
|
@exemplo.route('/') |
|
def index(): |
|
return redirect(url_for('meuexemplo.list')) |
|
|
|
@exemplo.route('/list') |
|
def list(): |
|
return 'Olá todos vocês.' |
|
</code></pre></p> |
|
|
|
<p>Se registrar o Blueprint com <code>prefix = /exemplo</code>, o <code>index()</code> irá |
|
fazer um redirect para <code>/exemplo/list</code>.</p> |
|
|
|
<p>Se registrar o Blueprint com <code>prefix = /</code>, o <code>index()</code> irá |
|
fazer um redirect para <code>/list</code>.</p> |
|
</section> |
|
|
|
<section> |
|
<p><code>url_for()</code> também funciona dentro de templates.</p> |
|
|
|
<p><pre><code data-trim> |
|
<button onClick='redirect("{{ url_for('meuexemplo.list') }}")'> |
|
</code></pre></p> |
|
</section> |
|
</section> |
|
|
|
<section> |
|
<section> |
|
<h2>Resumo</h2> |
|
</section> |
|
|
|
<section> |
|
<ul> |
|
<li>Sintaxe simples <span class='fragment'>(a sintaxe foi uma brincadeira de 1o. de abril)</span></li> |
|
<li class='fragment'>Controle centralizado de erros.</li> |
|
<li class='fragment'>Total controle sobre as respostas.</li> |
|
<li class='fragment'>Acesso total ao request.</li> |
|
<li class='fragment'>Módulos completamente isolados<span class='fragment'> mas ainda permite que esses sejam |
|
conectados por endpoints.</span></li> |
|
<li class='fragment'>Não comentado, mas o Werkzeug também expõe todo o controle da aplicação.</li> |
|
<li class='fragment'>Ainda não tem um ORM integrado, mas é fácil de plugar qualquer um.</li> |
|
<li class='fragment'>Pythônico.</li> |
|
</ul> |
|
</section> |
|
</section> |
|
|
|
<section data-background='_images/thats-all-folks.jpg'> |
|
<section></section> |
|
</section> |
|
</div> |
|
</div> |
|
|
|
<script src="_external/head.min.js"></script> |
|
<script src="_external/reveal.min.js"></script> |
|
|
|
<script> |
|
|
|
// Full list of configuration options available here: |
|
// https://github.com/hakimel/reveal.js#configuration |
|
Reveal.initialize({ |
|
controls: true, |
|
progress: true, |
|
history: true, |
|
center: true, |
|
|
|
theme: 'night', |
|
transition: 'linear', |
|
|
|
// Optional libraries used to extend on reveal.js |
|
dependencies: [ |
|
{ src: '_external/classList.js', condition: function() { return !document.body.classList; } }, |
|
{ src: '_external/marked.js', condition: function() { return !!document.querySelector( '[data-markdown]' ); } }, |
|
{ src: '_external/markdown.js', condition: function() { return !!document.querySelector( '[data-markdown]' ); } }, |
|
{ src: '_external/highlight.js', async: true, callback: function() { hljs.initHighlightingOnLoad(); } }, |
|
{ src: '_external/zoom.js', async: true, condition: function() { return !!document.body.classList; } }, |
|
{ src: '_external/notes.js', async: true, condition: function() { return !!document.body.classList; } } |
|
] |
|
}); |
|
|
|
</script> |
|
|
|
</body> |
|
</html>
|
|
|