ED_LRR/ed_lrr_gui/web/app.py

713 lines
22 KiB
Python
Raw Normal View History

2020-03-28 13:53:52 +00:00
# -*- coding: utf-8 -*-
2020-02-05 23:23:23 +00:00
from flask import (
Flask,
jsonify,
render_template,
redirect,
url_for,
send_from_directory,
request,
flash,
2020-03-28 13:53:52 +00:00
current_app,
2020-02-05 23:23:23 +00:00
)
2020-03-28 13:53:52 +00:00
from flask.cli import AppGroup
2020-02-05 23:23:23 +00:00
import uuid
import os
2020-03-28 13:53:52 +00:00
import click
2020-02-05 23:23:23 +00:00
from functools import wraps
from concurrent.futures.process import BrokenProcessPool
from datetime import datetime, timedelta
from webargs import fields, validate
from webargs.flaskparser import use_kwargs
from flask_executor import Executor
from flask_sqlalchemy import SQLAlchemy
from flask_bootstrap import Bootstrap
from flask_nav import Nav, register_renderer
from flask_nav.elements import Navbar, View
from flask_admin import Admin
from flask_admin.contrib.sqla import ModelView
from flask_wtf.csrf import CSRFProtect
from flask_login import (
LoginManager,
current_user,
logout_user,
UserMixin,
AnonymousUserMixin,
login_user,
login_required,
)
from flask_debugtoolbar import DebugToolbarExtension
from sqlalchemy_utils import generic_repr, JSONType, PasswordType, UUIDType
2020-03-28 13:53:52 +00:00
from sqlalchemy.orm import relationship
from sqlalchemy.types import DateTime
2020-02-05 23:23:23 +00:00
from jinja2.exceptions import TemplateNotFound
from .forms import RouteForm, LoginForm, RegisterForm, ChangePasswordForm
from .utils import prepare_route, BootsrapRenderer, is_safe_url
import _ed_lrr as ed_lrr
templates = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
app = Flask(__name__, template_folder=templates)
app.config.from_pyfile("config.py")
2020-03-28 13:53:52 +00:00
app.executor = executor = Executor(app)
app.db = db = SQLAlchemy(app)
app.bootstrap = bootstrap = Bootstrap(app)
app.csrf = csfr = CSRFProtect(app)
app.nav = nav = Nav(app)
app.login_manager = login_manager = LoginManager(app)
2020-02-05 23:23:23 +00:00
login_manager.login_view = "login"
login_manager.session_protection = "strong"
admin = Admin(app, name="ED_LRR", template_mode="bootstrap3")
2020-03-28 13:53:52 +00:00
app.debug = True
app.toolbar = toolbar = DebugToolbarExtension(app)
2020-02-05 23:23:23 +00:00
def wants_json_response():
2020-03-28 13:53:52 +00:00
return (
request.accept_mimetypes["application/json"]
>= request.accept_mimetypes["text/html"]
)
2020-02-05 23:23:23 +00:00
@app.errorhandler(422)
@app.errorhandler(400)
@app.errorhandler(500)
@app.errorhandler(404)
def handle_error(err):
if wants_json_response():
2020-03-28 13:53:52 +00:00
return jsonify(error=str(err), code=err.code), err.code
templates = ["error/{}.html".format(err.code), "error/default.html"]
2020-02-05 23:23:23 +00:00
try:
print(dir(err))
2020-03-28 13:53:52 +00:00
return render_template(templates, error=err), err.code
2020-02-05 23:23:23 +00:00
except TemplateNotFound:
return err.get_response()
2020-03-28 13:53:52 +00:00
2020-02-05 23:23:23 +00:00
def role_required(*roles):
def wrapper(fn):
@wraps(fn)
def decorated_view(*args, **kwargs):
if not current_user.is_authenticated():
2020-03-28 13:53:52 +00:00
return current_app.login_manager.unauthorized()
has_role = False
user = current_app.login_manager.reload_user()
2020-02-05 23:23:23 +00:00
for role in roles:
2020-03-28 13:53:52 +00:00
has_role |= user.has_role(role)
2020-02-05 23:23:23 +00:00
if not has_role:
return current_app.login_manager.unauthorized()
return fn(*args, **kwargs)
2020-03-28 13:53:52 +00:00
2020-02-05 23:23:23 +00:00
return decorated_view
2020-03-28 13:53:52 +00:00
2020-02-05 23:23:23 +00:00
return wrapper
2020-03-28 13:53:52 +00:00
2020-02-05 23:23:23 +00:00
@login_manager.user_loader
def load_user(user_name):
return User.query.get(user_name)
@login_manager.request_loader
def load_user_from_header(header_val):
2020-03-28 13:53:52 +00:00
for api_key in [request.args.get("api_key"), request.headers.get("X-API-Key")]:
2020-02-05 23:23:23 +00:00
if api_key:
user = User.query.filter_by(api_key=api_key).one_or_none()
if user:
return user
return None
return None
def left_nav():
2020-03-28 13:53:52 +00:00
links = [
View("Home", "index"),
View("Route", "route"),
View("Jobs", "status", job_id=None),
]
if current_user.has_role("admin") or current_user.has_role("worker_host"):
links.insert(2, View("Workers", "worker"))
return Navbar("E:D LRR", *links)
2020-02-05 23:23:23 +00:00
def right_nav():
links = [View("Login", "login"), View("Register", "register")]
if current_user.is_authenticated:
links = [View("Change Password", "change_password"), View("Logout", "logout")]
2020-03-28 13:53:52 +00:00
if current_user.has_role("admin"):
2020-02-05 23:23:23 +00:00
links = [View("Admin", "admin.index")] + links
return Navbar("", *links)
register_renderer(app, "bootstrap4", BootsrapRenderer)
nav.register_element("left_nav", left_nav)
nav.register_element("right_nav", right_nav)
def compute_route(args, kwargs):
return ed_lrr.route(*args, **kwargs)
class AnonymousUser(AnonymousUserMixin):
2020-03-28 13:53:52 +00:00
def has_role(self, role):
2020-02-05 23:23:23 +00:00
return False
@property
def roles(self):
return []
2020-03-28 13:53:52 +00:00
2020-02-05 23:23:23 +00:00
@roles.setter
2020-03-28 13:53:52 +00:00
def __set_roles(self, value):
2020-02-05 23:23:23 +00:00
raise NotImplementedError
login_manager.anonymous_user = AnonymousUser
@generic_repr
class Worker(db.Model):
id = db.Column(
UUIDType(binary=False, native=False), primary_key=True, default=uuid.uuid4
)
name = db.Column(db.String, unique=True)
2020-03-28 13:53:52 +00:00
current_job = db.Column(
UUIDType(binary=False, native=False),
db.ForeignKey("job.id"),
nullable=True,
default=None,
)
job = relationship("Job", backref="workers")
last_active = db.Column(DateTime, nullable=True, default=None)
2020-02-05 23:23:23 +00:00
owner_name = db.Column(
2020-03-28 13:53:52 +00:00
db.String, db.ForeignKey("user.name"), nullable=True, index=True
2020-02-05 23:23:23 +00:00
)
2020-03-28 13:53:52 +00:00
owner = relationship("User", backref="workers")
2020-02-05 23:23:23 +00:00
2020-03-28 13:53:52 +00:00
user_roles = db.Table(
"user_roles",
db.Column("user_name", db.String, db.ForeignKey("user.name"), primary_key=True),
db.Column("role_name", db.String, db.ForeignKey("role.name"), primary_key=True),
2020-02-05 23:23:23 +00:00
)
2020-03-28 13:53:52 +00:00
2020-02-05 23:23:23 +00:00
class Role(db.Model):
2020-03-28 13:53:52 +00:00
name = db.Column(db.String, unique=True, index=True, primary_key=True)
2020-02-05 23:23:23 +00:00
2020-03-28 13:53:52 +00:00
def __init__(self, name):
self.name = name
2020-02-05 23:23:23 +00:00
def __repr__(self):
return self.name
2020-03-28 13:53:52 +00:00
2020-02-05 23:23:23 +00:00
class User(db.Model, UserMixin):
2020-03-28 13:53:52 +00:00
name = db.Column(db.String, unique=True, index=True, primary_key=True)
2020-02-05 23:23:23 +00:00
is_active = db.Column(db.Boolean, default=False)
api_key = db.Column(
2020-03-28 13:53:52 +00:00
UUIDType(binary=False, native=False),
nullable=True,
default=uuid.uuid4,
index=True,
2020-02-05 23:23:23 +00:00
)
password = db.Column(PasswordType(schemes=["pbkdf2_sha512"], max_length=256))
created = db.Column(DateTime, default=datetime.today)
2020-03-28 13:53:52 +00:00
roles = db.relationship("Role", secondary="user_roles")
2020-02-05 23:23:23 +00:00
2020-03-28 13:53:52 +00:00
def add_roles(self, roles):
2020-02-05 23:23:23 +00:00
for role_name in roles:
2020-03-28 13:53:52 +00:00
role = Role.query.filter_by(name=role_name).one()
if role not in self.roles:
2020-02-05 23:23:23 +00:00
self.roles.append(role)
db.session.commit()
2020-03-28 13:53:52 +00:00
def has_role(self, role_name):
return (
Role.query.join(User.roles)
.filter(User.name == self.name, Role.name == role_name)
.count()
> 0
)
2020-02-05 23:23:23 +00:00
def reset_api_key(self):
2020-03-28 13:53:52 +00:00
self.api_key = uuid.uuid4()
2020-02-05 23:23:23 +00:00
db.session.add(self)
db.session.comiit()
def get_id(self):
return self.name
def __repr__(self):
return self.name
class Job(db.Model):
id = db.Column(
UUIDType(binary=False, native=False), primary_key=True, default=uuid.uuid4
)
user_name = db.Column(
2020-03-28 13:53:52 +00:00
db.String, db.ForeignKey("user.name"), nullable=True, index=True
2020-02-05 23:23:23 +00:00
)
func = db.Column(db.String)
args = db.Column(JSONType)
kwargs = db.Column(JSONType)
state = db.Column(JSONType, default={})
2020-03-28 13:53:52 +00:00
priority = db.Column(db.Integer, default=0, nullable=True)
2020-02-05 23:23:23 +00:00
created = db.Column(DateTime, default=datetime.today)
finished = db.Column(DateTime, nullable=True, default=None)
started = db.Column(DateTime, nullable=True, default=None)
last_update = db.Column(DateTime, nullable=True, default=None)
2020-03-28 13:53:52 +00:00
user = relationship("User", backref="jobs")
2020-02-05 23:23:23 +00:00
# ============================================================
def __repr__(self):
return str(self.id)
@property
def future(self):
fut = executor.futures._futures.get(self.id)
return fut
@property
def sort_key(self):
2020-03-28 13:53:52 +00:00
state_priorities = {
"Queued": 0,
"Starting": 1,
"Error": 1,
"Stalled": 1,
"Running": 1,
}
status_key = state_priorities.get(self.status[1], -1) + 1
user = 1 - int(self.user is not None)
return (user, -status_key, self.priority, self.created)
2020-02-05 23:23:23 +00:00
@property
def age(self):
2020-03-28 13:53:52 +00:00
dt = datetime.today() - self.created
2020-02-05 23:23:23 +00:00
return dt - dt % timedelta(seconds=1)
@classmethod
2020-03-28 13:53:52 +00:00
def get_next(cls):
for job in sorted(cls.query.all(), key=lambda v: v.sort_key):
if job.status[1] in ["Done"]:
2020-02-05 23:23:23 +00:00
continue
return job
return None
# return cls.query.
@property
def status(self):
2020-03-28 13:53:52 +00:00
# [
# ("primary", "Done"),
# ("danger", "Error"),
# ("info", "Stalled"),
# ("success", "Running"),
# ("secondary", "Starting"),
# ("warning", "Queued"),
# ]
# return states[self.id.int%len(states)]
2020-02-05 23:23:23 +00:00
if self.state.get("result"):
return ("primary", "Done")
if self.state.get("error"):
return ("danger", "Error")
if self.state.get("progress"):
if (datetime.today() - self.last_update).total_seconds() > (60 * 10):
return ("info", "Stalled")
return ("success", "Running")
if self.started is not None:
return ("secondary", "Starting")
return ("warning", "Queued")
@status.setter
def __set_status(self):
raise NotImplementedError
@property
def dict(self):
return {
"id": self.id,
"args": self.args,
"kwargs": self.kwargs,
"state": self.state,
"finished": self.finished,
"created": self.created,
"started": self.started,
}
@dict.setter
def __set_dict(self, value):
raise NotImplementedError
@property
def route(self):
try:
return prepare_route(self.state["result"])
except KeyError:
return None
@property
def t_rem(self):
if self.started is None:
return None
runtime = datetime.today() - self.started
try:
prc_done = self.state["progress"]["prc_done"]
if prc_done != 0:
t_rem = (runtime / prc_done) * (100 - prc_done)
return timedelta(seconds=round(t_rem.total_seconds(), 0))
return None
except KeyError:
return None
@t_rem.setter
def __set_t_rem(self, value):
raise NotImplementedError
@classmethod
def new(cls, func, args=None, kwargs=None):
args = args or ()
kwargs = kwargs or {}
job = cls(args=args, kwargs=kwargs, func=func.__qualname__)
job.__last_upd = 0.0
if current_user.is_authenticated:
job.user = current_user
db.session.add(job)
db.session.commit()
return job
def start(self):
global executor
self.state = {}
self.started = None
db.session.add(self)
db.session.commit()
args = self.args + [self.callback]
try:
future = executor.submit_stored(self.id, compute_route, args, self.kwargs)
except (BrokenProcessPool, RuntimeError) as e:
print("Error:", e)
print("Restarting Executor!")
executor = Executor(app)
future = executor.submit_stored(self.id, compute_route, args, self.kwargs)
future.add_done_callback(self.done)
def callback(self, cb_state):
try:
if self.started is None:
self.started = datetime.today()
if self.last_update is not None:
time_since_last_upd = (
datetime.today() - self.last_update
).total_seconds()
if time_since_last_upd < 5.0:
return
2020-03-28 13:53:52 +00:00
state = {}
2020-02-05 23:23:23 +00:00
state.update(self.state)
state.update({"progress": cb_state})
self.state = state
self.last_update = datetime.today()
db.session.add(self)
db.session.commit()
except Exception as e:
print(e)
def done(self, future):
print(self.id, "DONE")
2020-03-28 13:53:52 +00:00
state = {}
2020-02-05 23:23:23 +00:00
state.update(self.state)
executor.futures.pop(self.id)
exc = future.exception()
if exc:
state.update(
{"error": {"type": type(exc).__name__, "args": list(exc.args)}}
)
else:
state.update({"result": future.result()})
self.state = state
self.finished = datetime.now()
db.session.add(self)
db.session.commit()
db.create_all()
2020-03-28 13:53:52 +00:00
for role in ["admin", "user", "worker_host"]:
2020-02-05 23:23:23 +00:00
if Role.query.filter_by(name=role).one_or_none() is None:
db.session.add(Role(role))
2020-03-28 13:53:52 +00:00
def create_user(name, password, roles, active=False):
user = User.query.filter_by(name=name).one_or_none()
2020-02-05 23:23:23 +00:00
if user:
db.session.delete(user)
2020-03-28 13:53:52 +00:00
user = User(name=name, password=password, is_active=active)
2020-02-05 23:23:23 +00:00
user.add_roles(roles)
db.session.add(user)
db.session.commit()
return user
2020-03-28 13:53:52 +00:00
# create_user("admin", "admin", ["admin", "user"], True)
# create_user("user", "user", ["user"], True)
# create_user("host", "host", ["user", "worker_host"], True)
2020-02-05 23:23:23 +00:00
class SQLAView(ModelView):
column_exclude_list = ["password"]
column_editable_list = []
create_modal = True
edit_modal = True
can_view_details = True
column_display_pk = True
def is_accessible(self):
2020-03-28 13:53:52 +00:00
return current_user.is_authenticated and current_user.has_role("admin")
2020-02-05 23:23:23 +00:00
def inaccessible_callback(self, name, **kwargs):
return redirect(url_for("login"))
class UserView(SQLAView):
from wtforms import PasswordField
2020-03-28 13:53:52 +00:00
column_list = ("name", "active", "password", "api_key", "roles")
2020-02-05 23:23:23 +00:00
column_formatters = {
"password": lambda view, context, model, name: "",
"api_key": lambda view, context, model, name: model.api_key or "",
}
form_extra_fields = {"password": PasswordField("Password")}
class JobView(SQLAView):
# Job.id,Job.user,Job.func,Job.args,Job.kwargs,Job.state,Job.created,Job.finished,Job.started,Job.last_update
column_list = ("id", "status", "user", "created", "started", "finished")
2020-03-28 13:53:52 +00:00
column_formatters = {"status": lambda view, context, model, name: model.status[1]}
2020-02-05 23:23:23 +00:00
class WorkerView(SQLAView):
pass
# # Job.id,Job.user,Job.func,Job.args,Job.kwargs,Job.state,Job.created,Job.finished,Job.started,Job.last_update
# column_list = ("id", "status", "user", "created", "started", "finished")
# column_formatters = {
# "user": lambda view, context, model, name: model.user.name
# if model.user
# else "",
# "status": lambda view, context, model, name: model.status[1],
# }
2020-03-28 13:53:52 +00:00
2020-02-05 23:23:23 +00:00
admin.add_view(JobView(Job, db.session))
admin.add_view(UserView(User, db.session))
admin.add_view(SQLAView(Worker, db.session))
admin.add_view(SQLAView(Role, db.session))
def submit_job(func, *args, **kwargs):
job = Job.new(func, args, kwargs)
job.start()
return job.id
@app.route("/api/route", methods=["GET", "POST"])
@use_kwargs(
{
"jump_range": fields.Float(required=True),
"mode": fields.String(
missing="bfs", validate=validate.OneOf(["bfs", "greedy", "a-star"])
),
"systems": fields.DelimitedList(fields.String, required=True),
"permute": fields.String(
missing=None,
validate=validate.OneOf(
["off", "all", "keep_first", "keep_last", "keep_both"]
),
),
"primary": fields.Boolean(missing=False),
"factor": fields.Float(missing=0.5),
}
)
def api_route(_=None, **args):
if args["permute"] == "off":
args["permute"] = None
args["systems"] = [s.strip() for s in args["systems"]]
args = (
args["systems"],
args["jump_range"],
None,
args["mode"],
args["primary"],
args["permute"] is not None,
args["permute"] in ["keep_first", "keep_both"],
args["permute"] in ["keep_last", "keep_both"],
args["factor"],
None,
r"D:\devel\rust\ED_LRR\stars.csv",
2020-03-28 13:53:52 +00:00
app.config["ROUTE_WORKERS"],
2020-02-05 23:23:23 +00:00
)
return jsonify({"id": submit_job(ed_lrr.route, *args)})
@app.route("/api/status")
def api_status():
info = {"queued_jobs": len(executor.futures._futures)}
return jsonify(info)
@app.route("/api/whoami")
def api_whoami():
2020-03-28 13:53:52 +00:00
return jsonify({"name": current_user.name})
2020-02-05 23:23:23 +00:00
@app.route("/api/status/<uuid:job_id>")
def api_job_status(job_id):
job = Job.query.get_or_404(str(job_id))
return jsonify(job.dict)
@app.route("/static/<path:path>")
def send_static(path):
return send_from_directory("static", path)
@app.route("/route", methods=["GET", "POST"])
@login_required
def route():
form = RouteForm()
if form.validate_on_submit():
data = dict(form.data)
if data["permute"] == "off":
data["permute"] = None
del data["csrf_token"]
del data["submit"]
job = api_route(data)
return redirect(url_for("status", job_id=job.json["id"]))
return render_template("form.html", form=form, title="Plot Route")
2020-03-28 13:53:52 +00:00
@app.route("/status/", defaults={"job_id": None})
2020-02-05 23:23:23 +00:00
@app.route("/status/<uuid:job_id>")
@login_required
def status(job_id=None):
if job_id is not None:
2020-03-28 13:53:52 +00:00
job = Job.query.get_or_404(str(job_id))
2020-02-05 23:23:23 +00:00
return render_template("job.html", job=job)
2020-03-28 13:53:52 +00:00
return render_template("status.html", Job=Job, state=request.args.get("state"))
2020-02-05 23:23:23 +00:00
@app.route("/")
def index():
return render_template("index.html")
2020-03-28 13:53:52 +00:00
2020-02-05 23:23:23 +00:00
@app.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for("index"))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(name=form.data["username"]).one_or_none()
if (user is None) or (user.password != form.data["password"]):
flash("Invalid credentials!", "danger")
return redirect(url_for("login"))
if not user.is_active:
flash("Account is deactivated!", "warning")
return redirect(url_for("login"))
login_user(user, remember=form.data["remember"])
2020-03-28 13:53:52 +00:00
next = request.args.get("next")
2020-02-05 23:23:23 +00:00
if not is_safe_url(next):
2020-03-28 13:53:52 +00:00
next = None
2020-02-05 23:23:23 +00:00
return redirect(next or url_for("status"))
return render_template("form.html", form=form, title="Login")
@app.route("/register", methods=["GET", "POST"])
def register():
form = RegisterForm()
if form.validate_on_submit():
if User.query.filter_by(name=form.data["username"]).one_or_none() is not None:
2020-03-28 13:53:52 +00:00
flash("Username already exists", "danger")
2020-02-05 23:23:23 +00:00
return render_template("form.html", form=form, title="Register")
user = User()
user.name = form.data["username"]
user.password = form.data["password"]
db.session.add(user)
db.session.commit()
login_user(user)
return redirect(url_for("status"))
return render_template("form.html", form=form, title="Register")
@app.route("/change_password", methods=["GET", "POST"])
def change_password():
if current_user.is_anonymous:
return redirect(url_for("index"))
form = ChangePasswordForm()
if form.validate_on_submit():
if form.data["old_password"] == current_user.password:
current_user.password = form.data["password"]
flash("Password changed!", "success")
else:
flash("Wrong password!", "danger")
return render_template("form.html", form=form, title="Register")
return redirect(url_for("status"))
return render_template("form.html", form=form, title="Register")
2020-03-28 13:53:52 +00:00
2020-02-05 23:23:23 +00:00
@app.route("/workers/")
@login_required
def worker():
return render_template("workers.html")
2020-03-28 13:53:52 +00:00
2020-02-05 23:23:23 +00:00
@app.route("/logout")
def logout():
logout_user()
return redirect(url_for("login"))
@app.before_first_request
def resume_jobs():
2020-03-28 13:53:52 +00:00
print("NEXT:", Job.get_next())
2020-02-05 23:23:23 +00:00
with app.test_request_context():
for job in Job.query.all():
if job.status[1] != "Done":
print("Restarting {} with state {}".format(job.id, job.status[1]))
job.start()
2020-03-28 13:53:52 +00:00
user_cli = AppGroup('user', help="Manage users")
job_cli = AppGroup('job', help="Manage Jobs")
worker_cli = AppGroup('worker', help="Manage Workers")
@app.cli.command("gevent")
def cmd_gevent():
return
@user_cli.command("create")
@click.argument("name")
@click.option("-i", "--inactive", help="Crate account as inactive", is_flag=True, default=False)
@click.option("-r", "--role", help="Assign role to account", default=["user"], multiple=True)
@click.password_option("-p", "--password", help="Password for user")
def cmd_create_user(name, role, password, inactive):
"Create a new user"
create_user(name, password, role, not inactive)
print("User created!")
app.cli.add_command(user_cli)
app.cli.add_command(job_cli)
app.cli.add_command(worker_cli)
2020-02-05 23:23:23 +00:00
if __name__ == "__main__":
app.run(host="127.0.0.1", port=3777, debug=True)