Frontend¶
The frontend is provided by Python code using the
Flask microframework. This displays the web
interface, allowing a user to upload their input files, start a job, display
a list of all jobs in the system, and get back job results. This Python code
uses utility classes and functions in the saliweb.frontend
module,
together with functionality provided by Flask.
Initialization¶
The first step is to create a Python module that creates the web application
itself, using the saliweb.frontend.make_application()
function (which
in turn creates a Flask application and configures it).
For a web service ‘ModFoo’ this should be done in the Python module
frontend/modfoo/__init__.py
. This is fairly standard boilerplate:
import flask
import saliweb.frontend
parameters = [...]
app = saliweb.frontend.make_application(__name__, parameters)
The ‘parameters’ object here is a list of parameters the job requires at submission time, and will be described later.
Web page layout¶
Each web page has a similar layout (header, footer, links, and so on).
This is provided by a system-wide Jinja2 template
called saliweb/layout.html
, which can be seen
at GitHub.
This system-wide template should be overriden for each web service, by
providing a file frontend/modfoo/templates/layout.html
that ‘extends’
the template:
{% extends "saliweb/layout.html" %}
{%- block css %}
<link rel="stylesheet" type="text/css"
href="{{ url_for("static", filename='modfoo.css') }}" />
{%- endblock %}
{% block navigation %}
{{ get_navigation_links(
[(url_for("index"), "ModFoo Home"),
(url_for("job"), "Current ModFoo queue"),
(url_for("help"), "Help"),
(url_for("contact"), "Contact")])
}}
{% endblock %}
{% block sidebar %}
<p><i>Version <a href="https://github.com/salilab/modfoo">{{ config.VERSION }}</a></i></p>
{% endblock %}
{% block footer %}
<p>
Please cite Bob et al., JMB 2008.
</p>
{% endblock %}
This example demonstrates a few Jinja2 and Flask features:
Parts of the base template can be overriden using the
block
directive. Here, a custom stylesheet is added (by overriding thecss
block), links are added to all pages to the navigation bar at the top of the page (navigation
block), and the sidebar and footer (at the left and bottom of the page, which are blank in the system-wide template) are filled in.Links to other parts of the web service can be provided using Flask’s url_for function. This takes either the name of the Python function that renders the page (such as “index”; see below) or “static” to point to static files (such as stylesheets or images) in the web service’s
html
subdirectory.A number of global variables are available which can be substituted in using Jinja2’s
{{ }}
syntax, most notablyconfig
which stores web service configuration, such as the name and version inconfig.SERVICE_NAME
andconfig.VERSION
respectively.Some helper functions are available. Here the
get_navigation_links
function is used, which takes a list of (URL, description) pairs.
See the Jinja2 manual for more information on Jinja2 templates.
Displaying standard pages¶
The bulk of the functionality of the frontend is implemented by providing Python functions for each page using Flask routes.
For a typical web service, the index, submission, results and results file
pages need to be implemented by providing index
, job
, results
,
and results_file
functions, respectively.
These pages will be considered in turn.
Note
Additional pages (such as page to download the software, or a
help page) can be simply implemented by adding more Python
functions with appropriate routes (and, if appropriate, adding
links to these pages to layout.html
).
See below.
Index page¶
The index page is the first page seen when using the web service, and typically
displays a form allowing the user to set parameters and upload input files.
(In more complex web services this first form can lead to further forms for
advanced options, etc.) The Python code for this is straightforward - it
simply defines an index
function which uses the Flask
render_template
function to render a Jinja2 template, and is then
decorated using app.route
to tell Flask to use this function to service the ‘/’ URL:
@app.route('/')
def index():
return flask.render_template('index.html')
The Jinja2 template in turn looks like:
{% extends "layout.html" %}
{% block body %}
<form method="post" action="{{ url_for("job") }}"
enctype="multipart/form-data" name="modfooform">
<p>Job name (optional)</p>
<input type="text" name="job_name" />
<p>Email address (optional)</p>
<input type="text" name="email" value="{{ g.user.email }}" />
<p>Upload PDB file</p>
<input type="file" name="input_pdb" />
</form>
{% endblock %}
The template extends the previously-defined layout.html
so will get
the sidebar, footer, etc. defined there.
The form is set up to submit to the submission page
(url_for("job")
). It allows the user to upload a single PDB file, as
well as pick an optional name for their job and an optional email address
to be notified when the job completes.
Note that the email address is filled in using
g.user.email
. (g
is used by Flask to store
global data.)
If the user is logged in to the webserver, g.user
will
be a LoggedInUser
object, and
their email address will be available for use in this fashion (otherwise, the
user will simply have to input a suitable address if they want to be
notified). Similarly, g.user.modeller_key
provides the MODELLER license
key of the logged-in user.
The names of the form parameters above (job_name
, input_pdb
)
should also be described in the Python code frontend/modloop/__init__.py
for automated use of the service (see Automated use), by passing suitable
Parameter
and/or FileParameter
objects
when the application is created. Note that email
is omitted because
automated usage typically does not use email notification:
import flask
import saliweb.frontend
from saliweb.frontend import Parameter, FileParameter
parameters = [Parameter("job_name", "Job name", optional=True),
FileParameter("input_pdb", "PDB file to be refined")]
app = saliweb.frontend.make_application(__name__, parameters)
Submission page¶
The submission page is called when the user has input all the information
needed for the job. It is implemented by defining the job
function which
handles the /job
URL. This serves double duty - an HTTP POST to this URL
will submit a new job, while a GET will show all jobs in the system (the
queue).
Showing the queue is handled by the saliweb.frontend.render_queue_page()
function. Job submission should validate the provided information, then
actually submit the job to the backend. If validation fails, it should throw
an InputValidationError
exception; this will be handled by the web
framework as a message to the user asking them to fix the problem and resubmit.
(If some kind of internal error occurs, such as a file write failure, in this
or any other method, the user will see an error page and the server admin
will be notified by email to fix the problem.)
To submit the job, first create a saliweb.frontend.IncomingJob
object, which also creates a new directory for the job files. Put all
necessary input files in that directory, for example using
IncomingJob.get_path()
, then actually run the job by calling
IncomingJob.submit()
.
Note
‘Input files’ include PDB files, parameter files, etc. but not
shell scripts, Python scripts, or executables. These should never
be generated by the frontend, but instead should be generated by
the backend. If these files are generated by the frontend, it is
very easy for an unscrupulous end user to hack into the cluster by
compromising the web service. The backend should always check inputs
provided by the frontend (e.g. in the
preprocess()
method) for sanity before
running anything.
Note
When taking files as input, you can simply write them into the job directory
using their save method.
But never trust the filename provided by the user! Ideally, save with a
fixed generic name (e.g. input.pdb
). If this is not possible, use the
secure_filename
function to get a safe version of the filename.
Finally, the submission page should inform the user of the results URL
(IncomingJob.results_url
), so that they can obtain the results
when the job finishes. This uses the function
saliweb.frontend.render_submit_template()
, which will either
display an HTML page (similarly to Flask’s
render_template,
as before) or XML in the case of automated usage.
The example below reads in the PDB file provided by the user on the index page, checks to make sure it contains at least one ATOM record, then writes it into the job directory and finally submits the job:
@app.route('/job', methods=['GET', 'POST'])
def job():
if flask.request.method == 'GET':
return saliweb.frontend.render_queue_page()
else:
return submit_new_job()
def submit_new_job():
# Get form parameters
input_pdb = flask.request.files.get('input_pdb')
job_name = flask.request.form.get('job_name') or 'job'
email = flask.request.form.get('email')
# Validate input
file_contents = input_pdb.readlines()
atoms = 0
for line in file_contents:
if line.startswith('ATOM '):
atoms += 1
if atoms == 0:
raise saliweb.frontend.InputValidationError(
"PDB file contains no ATOM records!")
# Create job directory, add input files, then submit the job
job = saliweb.frontend.IncomingJob(job_name)
with open(job.get_path('input.pdb'), 'w') as fh:
fh.writelines(file_contents)
job.submit(email)
# Inform the user of the job name and results URL
return saliweb.frontend.render_submit_template(
'submit.html', email=email, job=job)
It uses the following template as submit.html
, providing the job
and email
variables, to notify the user:
{% extends "layout.html" %}
{% block body %}
<p>Your job {{ job.name }} has been submitted.</p>
<p>Results will be found at <a href="{{ job.results_url }}">this link</a>.</p>
{%- if email %}
<p>You will be notified at {{ email }} when job results are available.</p>
{%- endif %}
{% endblock %}
Results page¶
The results page is used to display the results of a job, and is implemented
by providing a results
function.
The function can either display the job results directly, or it can
display links to allow output files to be downloaded. In the latter case,
URLs to these files can be generated by calling
CompletedJob.get_results_file_url()
.
The example below assumes the backend generates a single output file on
success, output.pdb
, and a log file, log
, on failure.
It uses the utility function saliweb.frontend.get_completed_job()
to get a
CompletedJob
object from the URL (this will show an error message
if the job has not completed, or the password is invalid) and then looks for
files in the job directory using the CompletedJob.get_path()
method:
@app.route('/job/<name>')
def results(name):
job = saliweb.frontend.get_completed_job(name,
flask.request.args.get('passwd'))
# Determine whether the job completed successfully
if os.path.exists(job.get_path('output.pdb')):
template = 'results_ok.html'
else:
template = 'results_failed.html'
return saliweb.frontend.render_results_template(template, job=job)
This also uses the function
saliweb.frontend.render_results_template()
, which as before will either
display an HTML page (similarly to Flask’s
render_template),
or XML in the case of automated usage.
On successful job completion, it shows the results_ok.html
Jinja template,
which uses the CompletedJob.get_results_file_url()
method to show a
link to download output.pdb
and the
CompletedJob.get_results_available_time()
method
to tell the user how long the results page will be available for:
{% extends "layout.html" %}
{% block body %}
<p>Job '<b>{{ job.name }}</b>' has completed.</p>
<p><a href="{{ job.get_results_file_url('output.pdb') }}">Download output PDB</a>.</p>
{{ job.get_results_available_time() }}
{% endblock %}
On failure it shows a similar page that links to the log file:
{% extends "layout.html" %}
{% block body %}
<p>Your job '<b>{{ job.name }}</b>' failed to produce any output models.</p>
<p>For more information, you can
<a href="{{ job.get_results_file_url('log') }}">download the log file</a>.
</p>
{% endblock %}
Results files¶
If individual results files can be downloaded, a results_file
function
should be provided. Similar to the results page, this looks up the job
information using the URL, then sends it to the user using Flask’s
send_from_directory
function. The user is prevented from downloading other files that may be
present in the job directory, getting an HTTP 404 (file not found) error
instead, using the Flask abort function:
@app.route('/job/<name>/<path:fp>')
def results_file(name, fp):
job = saliweb.frontend.get_completed_job(name,
flask.request.args.get('passwd'))
if fp in ('output.pdb', 'log'):
return flask.send_from_directory(job.directory, fp)
else:
flask.abort(404)
Note
The “results files” don’t have to actually exist as real files in the job directory. Files can also be constructed on the fly and their contents returned to the user in a custom Flask Response object.
Alternative submit/results page for short jobs¶
For short jobs, it may not be desirable for job submission to pop up a page
containing a ‘results’ link that the user then needs to click on (as the job
may be complete by that point). In this case, the job submission page can
redirect straight to the job results page using
saliweb.frontend.redirect_to_results_page()
. To avoid the user seeing
an uninformative ‘job is still running’ page, this page should be
overridden using the still_running_template
argument to
saliweb.frontend.get_completed_job()
. (Normally this would display
something very similar to the submit page, but can auto-refresh if desired
using saliweb.frontend.StillRunningJob.get_refresh_time()
.) The resulting
logic would look similar to:
@app.route('/job', methods=['GET', 'POST'])
def job():
if flask.request.method == 'GET':
return saliweb.frontend.render_queue_page()
else:
return submit_new_job()
@app.route('/job/<name>')
def results(name):
job = get_completed_job(name, request.args.get('passwd'),
still_running_template='running.html')
... # as for the previous results page, above
def submit_new_job():
... # as for the previous submit page, above
job.submit(email)
# Go straight to the results page
return saliweb.frontend.redirect_to_results_page(job)
It uses the following template as running.html
, providing the job
variable (which is a saliweb.frontend.StillRunningJob
object),
to notify the user and auto-refresh:
{% extends "layout.html" %}
{% block meta %}
<meta http-equiv="refresh" content="{{ job.get_refresh_time(10) }}" />
{% endblock %}
{% block body %}
<p>Your job {{ job.name }} has been submitted.</p>
<p>The job is currently running. When it is complete, results will be found
here - simply refresh or bookmark this page.</p>
{%- if job.email %}
<p>You will be notified at {{ job.email }} when the job has finished.</p>
{%- endif %}
{% endblock %}
See LigScore at https://github.com/salilab/ligscore/ for a web service that uses this submit/results logic.
Additional pages¶
Additional pages can be added if desired, simply by adding more Python
functions with appropriate URLs (and adding the names of the functions to
the get_navigation_links
function in the layout.html
Jinja template).
Typically these just use render_template
to show some content:
@app.route('/contact')
def contact():
return flask.render_template('contact.html')
@app.route('/help')
def help():
return flask.render_template('help.html')
Controlling page access¶
By default, all pages can be viewed by both anonymous and logged-in users.
This can be modified, for example to restrict access only to named users, by
checking the value of flask.g.user
in any function, which
is either a LoggedInUser
object or None
.
If access should be denied, either raise an AccessDeniedError
exception
(which will return an error page to the user with a message)
or call flask.abort
with a suitable HTTP error code, e.g. 401, “unauthorized”
(which will return a generic error page without a message).