added python ldap / login pages

This commit is contained in:
2021-06-26 09:20:11 +10:00
parent d1ed80bd35
commit 371e2af64b
11 changed files with 134 additions and 5 deletions

View File

@@ -15,7 +15,7 @@ RUN truncate -s0 /tmp/preseed.cfg && \
apt-get install -y tzdata apt-get install -y tzdata
## cleanup of files from setup ## cleanup of files from setup
RUN rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* RUN rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN apt-get update && apt-get -y install python3-pip python3-psycopg2 libpq-dev gunicorn mediainfo cmake libgl1-mesa-glx libglib2.0-0 RUN apt-get update && apt-get -y install python3-pip python3-psycopg2 libpq-dev gunicorn mediainfo cmake libgl1-mesa-glx libglib2.0-0 python3-ldap
COPY requirements.txt requirements.txt COPY requirements.txt requirements.txt
RUN pip3 install -r requirements.txt RUN pip3 install -r requirements.txt
RUN pip3 install --upgrade pillow --user RUN pip3 install --upgrade pillow --user

3
README
View File

@@ -13,6 +13,9 @@ pip packages:
* datetime * datetime
* pytz * pytz
* face_recognition * face_recognition
* flask-login
* flask_login
* flask-ldap3-login
#### dlib (might need to install this before face_recognitioin, but it might not be needed, cmake clearly was) #### dlib (might need to install this before face_recognitioin, but it might not be needed, cmake clearly was)

2
ai.py
View File

@@ -8,6 +8,7 @@ from status import st, Status
from files import Entry, File, FileRefimgLink from files import Entry, File, FileRefimgLink
from person import Person, PersonRefimgLink from person import Person, PersonRefimgLink
from refimg import Refimg from refimg import Refimg
from flask_login import login_required, current_user
# pylint: disable=no-member # pylint: disable=no-member
@@ -15,6 +16,7 @@ from refimg import Refimg
# /aistats -> placholder for some sort of stats # /aistats -> placholder for some sort of stats
################################################################################ ################################################################################
@app.route("/aistats", methods=["GET", "POST"]) @app.route("/aistats", methods=["GET", "POST"])
@login_required
def aistats(): def aistats():
tmp=db.session.query(Entry,Person).join(File).join(FileRefimgLink).join(Refimg).join(PersonRefimgLink).join(Person).filter(FileRefimgLink.matched==True).all() tmp=db.session.query(Entry,Person).join(File).join(FileRefimgLink).join(Refimg).join(PersonRefimgLink).join(Person).filter(FileRefimgLink.matched==True).all()
entries=[] entries=[]

View File

@@ -16,6 +16,7 @@ import numpy
import cv2 import cv2
import time import time
import re import re
from flask_login import login_required, current_user
################################################################################ ################################################################################
# Local Class imports # Local Class imports
@@ -225,6 +226,7 @@ def GetEntriesInFolderView( cwd, prefix, noo, offset, how_many ):
# /file_list -> show detailed file list of files from import_path(s) # /file_list -> show detailed file list of files from import_path(s)
################################################################################ ################################################################################
@app.route("/file_list_ip", methods=["GET","POST"]) @app.route("/file_list_ip", methods=["GET","POST"])
@login_required
def file_list_ip(): def file_list_ip():
noo, grouping, how_many, offset, size, folders, cwd, root = ViewingOptions( request ) noo, grouping, how_many, offset, size, folders, cwd, root = ViewingOptions( request )
entries=[] entries=[]
@@ -240,6 +242,7 @@ def file_list_ip():
# /files -> show thumbnail view of files from import_path(s) # /files -> show thumbnail view of files from import_path(s)
################################################################################ ################################################################################
@app.route("/files_ip", methods=["GET", "POST"]) @app.route("/files_ip", methods=["GET", "POST"])
@login_required
def files_ip(): def files_ip():
noo, grouping, how_many, offset, size, folders, cwd, root = ViewingOptions( request ) noo, grouping, how_many, offset, size, folders, cwd, root = ViewingOptions( request )
@@ -261,6 +264,7 @@ def files_ip():
# /files -> show thumbnail view of files from storage_path # /files -> show thumbnail view of files from storage_path
################################################################################ ################################################################################
@app.route("/files_sp", methods=["GET", "POST"]) @app.route("/files_sp", methods=["GET", "POST"])
@login_required
def files_sp(): def files_sp():
noo, grouping, how_many, offset, size, folders, cwd, root = ViewingOptions( request ) noo, grouping, how_many, offset, size, folders, cwd, root = ViewingOptions( request )
entries=[] entries=[]
@@ -282,6 +286,7 @@ def files_sp():
# /files -> show thumbnail view of files from storage_path # /files -> show thumbnail view of files from storage_path
################################################################################ ################################################################################
@app.route("/files_rbp", methods=["GET", "POST"]) @app.route("/files_rbp", methods=["GET", "POST"])
@login_required
def files_rbp(): def files_rbp():
noo, grouping, how_many, offset, size, folders, cwd, root = ViewingOptions( request ) noo, grouping, how_many, offset, size, folders, cwd, root = ViewingOptions( request )
entries=[] entries=[]
@@ -303,6 +308,7 @@ def files_rbp():
# /search -> show thumbnail view of files from import_path(s) # /search -> show thumbnail view of files from import_path(s)
################################################################################ ################################################################################
@app.route("/search", methods=["GET","POST"]) @app.route("/search", methods=["GET","POST"])
@login_required
def search(): def search():
noo, grouping, how_many, offset, size, folders, cwd, root = ViewingOptions( request ) noo, grouping, how_many, offset, size, folders, cwd, root = ViewingOptions( request )
@@ -321,6 +327,7 @@ def search():
# /files/scannow -> allows us to force a check for new files # /files/scannow -> allows us to force a check for new files
################################################################################ ################################################################################
@app.route("/files/scannow", methods=["GET"]) @app.route("/files/scannow", methods=["GET"])
@login_required
def scannow(): def scannow():
job=NewJob("scannow" ) job=NewJob("scannow" )
st.SetAlert("success") st.SetAlert("success")
@@ -331,6 +338,7 @@ def scannow():
# /files/forcescan -> deletes old data in DB, and does a brand new scan # /files/forcescan -> deletes old data in DB, and does a brand new scan
################################################################################ ################################################################################
@app.route("/files/forcescan", methods=["GET"]) @app.route("/files/forcescan", methods=["GET"])
@login_required
def forcescan(): def forcescan():
job=NewJob("forcescan" ) job=NewJob("forcescan" )
st.SetAlert("success") st.SetAlert("success")
@@ -341,6 +349,7 @@ def forcescan():
# /files/scan_sp -> allows us to force a check for new files # /files/scan_sp -> allows us to force a check for new files
################################################################################ ################################################################################
@app.route("/files/scan_sp", methods=["GET"]) @app.route("/files/scan_sp", methods=["GET"])
@login_required
def scan_sp(): def scan_sp():
job=NewJob("scan_sp" ) job=NewJob("scan_sp" )
st.SetAlert("success") st.SetAlert("success")
@@ -349,6 +358,7 @@ def scan_sp():
@app.route("/fix_dups", methods=["POST"]) @app.route("/fix_dups", methods=["POST"])
@login_required
def fix_dups(): def fix_dups():
rows = db.engine.execute( "select e1.id as id1, f1.hash, d1.rel_path as rel_path1, d1.eid as did1, e1.name as fname1, p1.id as path1, p1.type_id as path_type1, e2.id as id2, d2.rel_path as rel_path2, d2.eid as did2, e2.name as fname2, p2.id as path2, p2.type_id as path_type2 from entry e1, file f1, dir d1, entry_dir_link edl1, path_dir_link pdl1, path p1, entry e2, file f2, dir d2, entry_dir_link edl2, path_dir_link pdl2, path p2 where e1.id = f1.eid and e2.id = f2.eid and d1.eid = edl1.dir_eid and edl1.entry_id = e1.id and edl2.dir_eid = d2.eid and edl2.entry_id = e2.id and p1.type_id != (select id from path_type where name = 'Bin') and p1.id = pdl1.path_id and pdl1.dir_eid = d1.eid and p2.type_id != (select id from path_type where name = 'Bin') and p2.id = pdl2.path_id and pdl2.dir_eid = d2.eid and f1.hash = f2.hash and e1.id != e2.id and f1.size_mb = f2.size_mb order by path1, rel_path1, fname1"); rows = db.engine.execute( "select e1.id as id1, f1.hash, d1.rel_path as rel_path1, d1.eid as did1, e1.name as fname1, p1.id as path1, p1.type_id as path_type1, e2.id as id2, d2.rel_path as rel_path2, d2.eid as did2, e2.name as fname2, p2.id as path2, p2.type_id as path_type2 from entry e1, file f1, dir d1, entry_dir_link edl1, path_dir_link pdl1, path p1, entry e2, file f2, dir d2, entry_dir_link edl2, path_dir_link pdl2, path p2 where e1.id = f1.eid and e2.id = f2.eid and d1.eid = edl1.dir_eid and edl1.entry_id = e1.id and edl2.dir_eid = d2.eid and edl2.entry_id = e2.id and p1.type_id != (select id from path_type where name = 'Bin') and p1.id = pdl1.path_id and pdl1.dir_eid = d1.eid and p2.type_id != (select id from path_type where name = 'Bin') and p2.id = pdl2.path_id and pdl2.dir_eid = d2.eid and f1.hash = f2.hash and e1.id != e2.id and f1.size_mb = f2.size_mb order by path1, rel_path1, fname1");
@@ -374,6 +384,7 @@ def fix_dups():
return render_template("dups.html", DD=DD, pagesize=pagesize ) return render_template("dups.html", DD=DD, pagesize=pagesize )
@app.route("/rm_dups", methods=["POST"]) @app.route("/rm_dups", methods=["POST"])
@login_required
def rm_dups(): def rm_dups():
jex=[] jex=[]
@@ -398,6 +409,7 @@ def rm_dups():
return render_template("base.html") return render_template("base.html")
@app.route("/restore_files", methods=["POST"]) @app.route("/restore_files", methods=["POST"])
@login_required
def restore_files(): def restore_files():
jex=[] jex=[]
for el in request.form: for el in request.form:
@@ -409,6 +421,7 @@ def restore_files():
return render_template("base.html") return render_template("base.html")
@app.route("/delete_files", methods=["POST"]) @app.route("/delete_files", methods=["POST"])
@login_required
def delete_files(): def delete_files():
jex=[] jex=[]
for el in request.form: for el in request.form:
@@ -420,6 +433,7 @@ def delete_files():
return render_template("base.html") return render_template("base.html")
@app.route("/move_files", methods=["POST"]) @app.route("/move_files", methods=["POST"])
@login_required
def move_files(): def move_files():
jex=[] jex=[]
for el in request.form: for el in request.form:
@@ -435,6 +449,7 @@ def move_files():
# we create/use symlinks in static/ to reference the images to show # we create/use symlinks in static/ to reference the images to show
################################################################################ ################################################################################
@app.route("/static/<filename>") @app.route("/static/<filename>")
@login_required
def custom_static(filename): def custom_static(filename):
return send_from_directory("static/", filename) return send_from_directory("static/", filename)

5
job.py
View File

@@ -6,9 +6,11 @@ from sqlalchemy import Sequence
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from status import st, Status from status import st, Status
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask_login import login_required, current_user
import pytz import pytz
import socket import socket
from shared import PA_JOB_MANAGER_HOST, PA_JOB_MANAGER_PORT from shared import PA_JOB_MANAGER_HOST, PA_JOB_MANAGER_PORT
from flask_login import login_required, current_user
# pylint: disable=no-member # pylint: disable=no-member
@@ -93,6 +95,7 @@ def NewJob(name, num_files="0", wait_for=None, jex=None ):
# /jobs -> show current settings # /jobs -> show current settings
################################################################################ ################################################################################
@app.route("/jobs", methods=["GET"]) @app.route("/jobs", methods=["GET"])
@login_required
def jobs(): def jobs():
page_title='Job list' page_title='Job list'
jobs = Job.query.order_by(Job.id.desc()).all() jobs = Job.query.order_by(Job.id.desc()).all()
@@ -103,6 +106,7 @@ def jobs():
# /job/<id> -> GET -> shows status/history of jobs # /job/<id> -> GET -> shows status/history of jobs
################################################################################ ################################################################################
@app.route("/job/<id>", methods=["GET"]) @app.route("/job/<id>", methods=["GET"])
@login_required
def joblog(id): def joblog(id):
page_title='Show Job Details' page_title='Show Job Details'
joblog = Job.query.get(id) joblog = Job.query.get(id)
@@ -118,6 +122,7 @@ def joblog(id):
# /job/<id> -> GET -> shows status/history of jobs # /job/<id> -> GET -> shows status/history of jobs
################################################################################ ################################################################################
@app.route("/wakeup", methods=["GET"]) @app.route("/wakeup", methods=["GET"])
@login_required
def wakeup(): def wakeup():
WakePAJobManager() WakePAJobManager()
return render_template("base.html") return render_template("base.html")

89
main.py
View File

@@ -1,4 +1,4 @@
from flask import Flask, render_template, request, redirect, jsonify from flask import Flask, render_template, request, redirect, jsonify, url_for, render_template_string
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from flask_marshmallow import Marshmallow from flask_marshmallow import Marshmallow
@@ -7,6 +7,13 @@ from wtforms import SubmitField, StringField, HiddenField, SelectField, IntegerF
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from status import st, Status from status import st, Status
from shared import CreateSelect, CreateFoldersSelect, LocationIcon, DB_URL from shared import CreateSelect, CreateFoldersSelect, LocationIcon, DB_URL
from flask_login import login_required, current_user
# for ldap auth
from flask_ldap3_login import LDAP3LoginManager
from flask_login import LoginManager, login_user, UserMixin, current_user
from flask_ldap3_login.forms import LDAPLoginForm
import re import re
import socket import socket
@@ -23,9 +30,30 @@ app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = DB_URL app.config['SQLALCHEMY_DATABASE_URI'] = DB_URL
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config.from_mapping( SECRET_KEY=b'\xd6\x04\xbdj\xfe\xed$c\x1e@\xad\x0f\x13,@G') app.config.from_mapping( SECRET_KEY=b'\xd6\x04\xbdj\xfe\xed$c\x1e@\xad\x0f\x13,@G')
# ldap config vars: (the last one is required, or python ldap freaks out)
app.config['LDAP_HOST'] = 'mara.ddp.net'
app.config['LDAP_BASE_DN'] = 'dc=depaoli,dc=id,dc=au'
app.config['LDAP_USER_DN'] = 'ou=users'
app.config['LDAP_GROUP_DN'] = 'ou=groups'
app.config['LDAP_USER_RDN_ATTR'] = 'cn'
app.config['LDAP_USER_LOGIN_ATTR'] = 'uid'
app.config['LDAP_BIND_USER_DN'] = None
app.config['LDAP_BIND_USER_PASSWORD'] = None
app.config['LDAP_GROUP_OBJECT_FILTER'] = '(objectclass=posixGroup)'
db = SQLAlchemy(app) db = SQLAlchemy(app)
ma = Marshmallow(app) ma = Marshmallow(app)
Bootstrap(app) Bootstrap(app)
login_manager = LoginManager(app) # Setup a Flask-Login Manager
ldap_manager = LDAP3LoginManager(app) # Setup a LDAP3 Login Manager.
login_manager.login_view = "login" # default login route, failed with url_for, so hard-coded
# Create a dictionary to store the users in when they authenticate
# This example stores users in memory.
users = {}
################################# Now, import non-book classes ################################### ################################# Now, import non-book classes ###################################
from settings import Settings from settings import Settings
@@ -50,11 +78,70 @@ app.jinja_env.globals['CreateFoldersSelect'] = CreateFoldersSelect
app.jinja_env.globals['LocationIcon'] = LocationIcon app.jinja_env.globals['LocationIcon'] = LocationIcon
app.jinja_env.globals['StoragePathNames'] = StoragePathNames app.jinja_env.globals['StoragePathNames'] = StoragePathNames
# Declare an Object Model for the user, and make it comply with the
# flask-login UserMixin mixin.
class User(UserMixin):
def __init__(self, dn, username, data):
self.dn = dn
self.username = username
self.data = data
def __repr__(self):
return self.dn
def get_id(self):
return self.dn
# Declare a User Loader for Flask-Login.
# Simply returns the User if it exists in our 'database', otherwise
# returns None.
@login_manager.user_loader
def load_user(id):
if id in users:
return users[id]
return None
# Declare The User Saver for Flask-Ldap3-Login
# This method is called whenever a LDAPLoginForm() successfully validates.
# Here you have to save the user, and return it so it can be used in the
# login controller.
@ldap_manager.save_user
def save_user(dn, username, data, memberships):
user = User(dn, username, data)
users[dn] = user
return user
# default page, just the navbar # default page, just the navbar
@app.route("/", methods=["GET"]) @app.route("/", methods=["GET"])
@login_required
def main_page(): def main_page():
# Redirect users who are not logged in.
if not current_user or current_user.is_anonymous:
return redirect(url_for('login'))
return render_template("base.html") return render_template("base.html")
@app.route('/login', methods=['GET', 'POST'])
def login():
# Instantiate a LDAPLoginForm which has a validator to check if the user
# exists in LDAP.
form = LDAPLoginForm()
form.submit.label.text="Login"
if form.validate_on_submit():
# Successfully logged in, We can now access the saved user object
# via form.user.
login_user(form.user, remember=True) # Tell flask-login to log them in.
next = request.args.get("next")
if next:
return redirect(next) # Send them back where they came from
else:
return redirect('/')
return render_template("login.html", form=form)
if __name__ == "__main__": if __name__ == "__main__":
if hostname == PROD_HOST: if hostname == PROD_HOST:
app.run(ssl_context=('/etc/letsencrypt/live/book.depaoli.id.au/cert.pem', '/etc/letsencrypt/live/book.depaoli.id.au/privkey.pem'), host="0.0.0.0", debug=False) app.run(ssl_context=('/etc/letsencrypt/live/book.depaoli.id.au/cert.pem', '/etc/letsencrypt/live/book.depaoli.id.au/privkey.pem'), host="0.0.0.0", debug=False)

View File

@@ -6,6 +6,7 @@ from sqlalchemy import Sequence
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from status import st, Status from status import st, Status
from refimg import Refimg from refimg import Refimg
from flask_login import login_required, current_user
# pylint: disable=no-member # pylint: disable=no-member
@@ -56,6 +57,7 @@ class PersonForm(FlaskForm):
# /persons -> GET only -> prints out list of all persons # /persons -> GET only -> prints out list of all persons
################################################################################ ################################################################################
@app.route("/persons", methods=["GET"]) @app.route("/persons", methods=["GET"])
@login_required
def persons(): def persons():
persons = Person.query.all() persons = Person.query.all()
return render_template("persons.html", persons=persons) return render_template("persons.html", persons=persons)
@@ -65,6 +67,7 @@ def persons():
# /person -> GET/POST -> creates a new person type and when created, takes you back to /persons # /person -> GET/POST -> creates a new person type and when created, takes you back to /persons
################################################################################ ################################################################################
@app.route("/person", methods=["GET", "POST"]) @app.route("/person", methods=["GET", "POST"])
@login_required
def new_person(): def new_person():
form = PersonForm(request.form) form = PersonForm(request.form)
page_title='Create new Person' page_title='Create new Person'
@@ -90,6 +93,7 @@ def new_person():
# person # person
################################################################################ ################################################################################
@app.route("/person/<id>", methods=["GET", "POST"]) @app.route("/person/<id>", methods=["GET", "POST"])
@login_required
def person(id): def person(id):
form = PersonForm(request.form) form = PersonForm(request.form)
page_title='Edit Person' page_title='Edit Person'

View File

@@ -6,6 +6,7 @@ from sqlalchemy import Sequence
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from status import st, Status from status import st, Status
import os import os
from flask_login import login_required, current_user
# pylint: disable=no-member # pylint: disable=no-member
@@ -51,6 +52,7 @@ class RefimgForm(FlaskForm):
# /refimgs -> GET only -> prints out list of all refimgs # /refimgs -> GET only -> prints out list of all refimgs
################################################################################ ################################################################################
@app.route("/refimgs", methods=["GET"]) @app.route("/refimgs", methods=["GET"])
@login_required
def refimgs(): def refimgs():
refimgs = Refimg.query.all() refimgs = Refimg.query.all()
return render_template("refimgs.html", refimgs=refimgs) return render_template("refimgs.html", refimgs=refimgs)
@@ -59,6 +61,7 @@ def refimgs():
# /refimg -> GET/POST -> creates a new refimg type and when created, takes you back to /refimgs # /refimg -> GET/POST -> creates a new refimg type and when created, takes you back to /refimgs
################################################################################ ################################################################################
@app.route("/refimg", methods=["GET", "POST"]) @app.route("/refimg", methods=["GET", "POST"])
@login_required
def new_refimg(): def new_refimg():
form = RefimgForm(request.form) form = RefimgForm(request.form)
page_title='Create new Reference Image' page_title='Create new Reference Image'
@@ -88,6 +91,7 @@ def new_refimg():
# refimg # refimg
################################################################################ ################################################################################
@app.route("/refimg/<id>", methods=["GET", "POST"]) @app.route("/refimg/<id>", methods=["GET", "POST"])
@login_required
def refimg(id): def refimg(id):
form = RefimgForm(request.form) form = RefimgForm(request.form)
page_title='Edit Reference Image' page_title='Edit Reference Image'

View File

@@ -1,4 +1,6 @@
flask flask
flask_login
flask-ldap3-login
sqlalchemy sqlalchemy
flask-sqlalchemy flask-sqlalchemy
SQLAlchemy-serializer SQLAlchemy-serializer

View File

@@ -5,6 +5,7 @@ from main import db, app, ma
from sqlalchemy import Sequence from sqlalchemy import Sequence
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from status import st, Status from status import st, Status
from flask_login import login_required, current_user
# pylint: disable=no-member # pylint: disable=no-member
@@ -45,6 +46,7 @@ class SettingsForm(FlaskForm):
# /settings -> show current settings # /settings -> show current settings
################################################################################ ################################################################################
@app.route("/settings", methods=["GET", "POST"]) @app.route("/settings", methods=["GET", "POST"])
@login_required
def settings(): def settings():
form = SettingsForm(request.form) form = SettingsForm(request.form)
page_title='Settings' page_title='Settings'

View File

@@ -8,9 +8,9 @@
{% for job in jobs %} {% for job in jobs %}
{% if job.state == "Failed" %} {% if job.state == "Failed" %}
row='<tr><td class="bg-danger"><a class="text-white" href="{{url_for('joblog', id=job.id)}}">Job #{{job.id}} - {{job.name}}</a>' row='<tr><td class="table-danger"><a href="{{url_for('joblog', id=job.id)}}">Job #{{job.id}} - {{job.name}}</a>'
{% elif job.state == "Withdrawn" %} {% elif job.state == "Withdrawn" %}
row='<tr><td class="bg-secondary"><a class="text-white" href="{{url_for('joblog', id=job.id)}}">Job #{{job.id}} - {{job.name}}</a>' row='<tr><td class="table-secondary"><i><a href="{{url_for('joblog', id=job.id)}}">Job #{{job.id}} - {{job.name}}</a>'
{% else %} {% else %}
row='<tr><td><a href="{{url_for('joblog', id=job.id)}}">Job #{{job.id}} - {{job.name}}</a>' row='<tr><td><a href="{{url_for('joblog', id=job.id)}}">Job #{{job.id}} - {{job.name}}</a>'
{% endif %} {% endif %}
@@ -21,7 +21,12 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}
row+= '</td><td>{{job.start_time}}</td><td>'
{% if job.state == "Withdrawn" %}
row+= '</td><td>{{job.start_time}}</i></td><td>'
{% else %}
row+= '</td><td>{{job.start_time}}</td><td>'
{% endif %}
{% if job.pa_job_state != "Completed" %} {% if job.pa_job_state != "Completed" %}
{% if job.num_files and job.num_files > 0 %} {% if job.num_files and job.num_files > 0 %}
{% set prog=(job.current_file_num/job.num_files*100)|round|int %} {% set prog=(job.current_file_num/job.num_files*100)|round|int %}