Add a visible login and register page

Add users page to admin site
Change admin site to use permissions instead of roles
Fix issue with flask-security-too user_datastore giving error
This commit is contained in:
Matthew Welch 2021-04-22 16:48:32 -07:00
parent 0738cf84be
commit 105706680b
12 changed files with 409 additions and 52 deletions

View File

@ -1,42 +1,41 @@
import json import json
from flask import render_template, Blueprint, request, jsonify, redirect, url_for from flask import render_template, Blueprint, request, jsonify, redirect, url_for
from flask_security import roles_required from flask_security import roles_required, permissions_accepted
from QuizTheWord import database from QuizTheWord import database
from QuizTheWord.database import get_question
Admin = Blueprint("admin", __name__, url_prefix="/admin", template_folder="templates", static_folder="static") Admin = Blueprint("admin", __name__, url_prefix="/admin", template_folder="templates", static_folder="static")
@Admin.route("/") @Admin.route("/")
@roles_required("admin") @permissions_accepted("admin_site_access")
def index(): def index():
questions = database.get_unhealthy_questions() questions = database.get_unhealthy_questions()
return render_template("admin/index.html", title="admin", questions=questions) return render_template("admin/index.html", title="admin", questions=questions)
@Admin.route("/questions/") @Admin.route("/questions/")
@roles_required("admin") @permissions_accepted("question_edit", "question_add")
def questions(): def questions():
return render_template("admin/question_list.html") return render_template("admin/question_list.html")
@Admin.route("/questions/edit/<int:question_id>", methods=["GET", "POST"]) @Admin.route("/questions/edit/<int:question_id>", methods=["GET", "POST"])
@roles_required("admin") @permissions_accepted("question_edit")
def edit_question(question_id): def edit_question(question_id):
if request.method == "POST": if request.method == "POST":
data = parse_question_form_data(request.form) data = parse_question_form_data(request.form)
database.update_question(question_id, data) database.update_question(question_id, data)
return redirect(url_for("admin.edit_question", question_id=question_id)) return redirect(url_for("admin.edit_question", question_id=question_id))
question: database.AllQuestions = get_question(database.AllQuestions, question_id) question: database.AllQuestions = database.get_question(database.AllQuestions, question_id)
if "application/json" in request.accept_mimetypes.values(): if "application/json" in request.accept_mimetypes.values():
return jsonify(question.get_dict()) return jsonify(question.get_dict())
return render_template("admin/edit_question.html", question=question) return render_template("admin/edit_question.html", question=question)
@Admin.route("/question_query") @Admin.route("/questions/query")
@roles_required("admin") @permissions_accepted("admin_site_access")
def query_questions(): def query_questions():
offset = request.args.get("offset", type=int) offset = request.args.get("offset", type=int)
limit = request.args.get("limit", type=int) limit = request.args.get("limit", type=int)
@ -61,6 +60,7 @@ def query_questions():
@Admin.route("/questions/add", methods=["GET", "POST"]) @Admin.route("/questions/add", methods=["GET", "POST"])
@permissions_accepted("question_add")
def create_question(): def create_question():
if request.method == "POST": if request.method == "POST":
data = parse_question_form_data(request.form) data = parse_question_form_data(request.form)
@ -70,6 +70,50 @@ def create_question():
return render_template("admin/edit_question.html", question=question) return render_template("admin/edit_question.html", question=question)
@Admin.route("/users/")
@permissions_accepted("user_edit")
def users():
return render_template("admin/user_list.html")
@Admin.route("/users/edit/<int:user_id>", methods=["GET", "POST"])
@permissions_accepted("user_edit")
def edit_user(user_id):
if request.method == "POST":
data = parse_user_form_data(request.form)
database.update_user(user_id, data)
return redirect(url_for("admin.edit_user", user_id=user_id))
user = database.get_user(user_id)
roles = database.get_all_roles()
if "application/json" in request.accept_mimetypes.values():
return jsonify(user.get_dict())
return render_template("admin/edit_user.html", user=user, roles=roles)
@Admin.route("/users/query")
@permissions_accepted("user_edit")
def query_users():
offset = request.args.get("offset", type=int)
limit = request.args.get("limit", type=int)
sort = request.args.get("sort")
order = request.args.get("order")
query = parse_user_query(request.args.get("filter"))
result = database.query_users(offset, limit, query, sort, order)
response_dict = {
"total": result[1],
"rows": [],
}
for user in result[0]:
response_dict["rows"].append({
"id": user.user_id,
"user_id": user.user_id,
"username": user.username,
"email": user.email,
})
return jsonify(response_dict)
def parse_question_query(query): def parse_question_query(query):
if query: if query:
query: dict = json.loads(query) query: dict = json.loads(query)
@ -99,3 +143,21 @@ def parse_question_form_data(form):
if hidden_answer_difficulty is not None: if hidden_answer_difficulty is not None:
data["hidden_answer_difficulty"] = hidden_answer_difficulty data["hidden_answer_difficulty"] = hidden_answer_difficulty
return data return data
def parse_user_form_data(form):
data = {
"username": form.get("username"),
"email": form.get("email"),
"roles": form.getlist("roles")
}
return data
def parse_user_query(query):
if query:
query: dict = json.loads(query)
# if "admin" in query.keys():
# query["admin"] = True if query["admin"] == "true" else False
return query
return None

View File

@ -7,7 +7,7 @@
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}"> <link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
<script src="https://kit.fontawesome.com/f2d4307e62.js" crossorigin="anonymous"></script> <script src="https://kit.fontawesome.com/f2d4307e62.js" crossorigin="anonymous"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-eOJMYsd53ii+scO/bJGFsiCZc+5NDVN2yr8+0RDqr0Ql0h+rP48ckxlpbzKgwra6" crossorigin="anonymous"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-eOJMYsd53ii+scO/bJGFsiCZc+5NDVN2yr8+0RDqr0Ql0h+rP48ckxlpbzKgwra6" crossorigin="anonymous">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootswatch/4.5.2/cosmo/bootstrap.min.css"> {# <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootswatch/4.5.2/cosmo/bootstrap.min.css">#}
<link rel="stylesheet" href="{{ url_for('admin.static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('admin.static', filename='style.css') }}">
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
@ -26,6 +26,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('admin.questions') }}">Questions</a> <a class="nav-link" href="{{ url_for('admin.questions') }}">Questions</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin.users') }}">Users</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -4,15 +4,15 @@
<form class="container mt-5" method="post"> <form class="container mt-5" method="post">
<div class="form-group"> <div class="form-group">
<label for="question_text">Question</label> <label for="question_text">Question</label>
<input id="question_text" name="question_text" type="text" class="form-control" value="{{ question.question_text }}"> <input id="question_text" name="question_text" type="text" class="form-control" value="{{ question.question_text if question.question_text is not none else "" }}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="answer">Answer</label> <label for="answer">Answer</label>
<input id="answer" name="answer" type="text" class="form-control" value="{{ question.answer }}"> <input id="answer" name="answer" type="text" class="form-control" value="{{ question.answer if question.answer is not none else "" }}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="addresses">Bible Verses</label> <label for="addresses">Bible Verses</label>
<input id="addresses" name="addresses" type="text" class="form-control" value="{{ question.addresses }}"> <input id="addresses" name="addresses" type="text" class="form-control" value="{{ question.addresses if question.addresses is not none else "" }}">
</div> </div>
<label class="mt-3">Multiple Choice</label> <label class="mt-3">Multiple Choice</label>
<div class="container border mb-1"> <div class="container border mb-1">
@ -98,7 +98,7 @@
return $("<div>") return $("<div>")
.addClass("form-group") .addClass("form-group")
.append($("<select>") .append($("<select>")
.addClass("custom-select") .addClass("form-select")
.attr({"id": category + "_difficulty", "name": category + "_difficulty"}) .attr({"id": category + "_difficulty", "name": category + "_difficulty"})
.append(option_none) .append(option_none)
.append(option_easy) .append(option_easy)

View File

@ -0,0 +1,25 @@
{% extends "admin/base.html" %}
{% block body %}
<form class="container mt-5" method="post">
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input id="email" type="email" class="form-control" name="email" value="{{ user.email if user.email is not none else "" }}">
</div>
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input id="username" type="text" class="form-control" name="username" value="{{ user.username if user.username is not none else "" }}">
</div>
<div class="mb-3">
<div class="list-group">
{% for role in roles %}
<label class="list-group-item">
<input class="form-check-input me-1" type="checkbox" name="roles" value="{{ role.name }}" {{ "checked" if user.has_role(role) else "" }}>
{{ role.name }}
</label>
{% endfor %}
</div>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
{% endblock %}

View File

@ -29,7 +29,7 @@
} }
$("#question-table").bootstrapTable({ $("#question-table").bootstrapTable({
url: "/admin/question_query", url: "{{ url_for('admin.query_questions') }}",
pagination: true, pagination: true,
sidePagination: "server", sidePagination: "server",
filterControl: true, filterControl: true,
@ -37,8 +37,6 @@
showRefresh: true, showRefresh: true,
searchOnEnterKey: true, searchOnEnterKey: true,
onClickRow: (item, element) => { onClickRow: (item, element) => {
console.log(item);
console.log(element);
window.location.href = `/admin/questions/edit/${item["question_id"]}` window.location.href = `/admin/questions/edit/${item["question_id"]}`
}, },
onLoadSuccess: enable_input, onLoadSuccess: enable_input,

View File

@ -0,0 +1,67 @@
{% extends "admin/base.html" %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('admin.static', filename='bootstrap-table.min.css') }}">
<link rel="stylesheet" href="{{ url_for('admin.static', filename='bootstrap-table-filter-control.min.css') }}">
{% endblock %}
{% block body %}
<table id="user-table" class="table"></table>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('admin.static', filename='bootstrap-table.min.js') }}"></script>
<script src="{{ url_for('admin.static', filename='bootstrap-table-filter-control.min.js') }}"></script>
<script>
function disable_input() {
$("input, select").attr("disabled", true);
}
function enable_input() {
$("input, select").removeAttr("disabled");
}
$("#user-table").bootstrapTable({
url: "{{ url_for('admin.query_users') }}",
pagination: true,
sidePagination: "server",
filterControl: true,
showRefresh: true,
searchOnEnterKey: true,
onClickRow: (item, element) => {
window.location.href = `/admin/users/edit/${item["user_id"]}`
},
onLoadSuccess: enable_input,
onPageChange: disable_input,
onSearch: disable_input,
{#buttons: [#}
{# {#}
{# html: "<a class='btn btn-secondary' href='{{ url_for('admin.create_user') }}'>Add User</a>",#}
{# attributes: {#}
{# title: "Add a new user to the database."#}
{# }#}
{# }#}
{#],#}
columns: [
{
field: "user_id",
title: "User ID",
sortable: true,
filterControl: "input",
},
{
field: "username",
title: "Username",
sortable: true,
filterControl: "input",
},
{
field: "email",
title: "Email",
sortable: true,
filterControl: "input",
},
]
})
</script>
{% endblock %}

View File

@ -1,19 +1,28 @@
import os import os
from flask import request, render_template, jsonify, Flask from flask import request, render_template, jsonify, Flask, url_for, redirect, flash
from flask_security import Security, SQLAlchemySessionUserDatastore from flask_security import Security, SQLAlchemySessionUserDatastore, login_user, logout_user, current_user
from QuizTheWord import database from QuizTheWord import database
from QuizTheWord.admin import admin from QuizTheWord.admin import admin
from QuizTheWord import config # from QuizTheWord import config
app = Flask(__name__) app = Flask(__name__)
environment_configuration = os.environ.get('CONFIGURATION_SETUP', "QuizTheWord.config.Development") environment_configuration = os.environ.get('CONFIGURATION_SETUP', "QuizTheWord.config.Development")
with app.app_context(): with app.app_context():
app.config.from_object(environment_configuration) app.config.from_object(environment_configuration)
user_datastore = SQLAlchemySessionUserDatastore(database.get_session(), database.User, database.Role) user_datastore = SQLAlchemySessionUserDatastore(database.get_session(), database.User, database.Role)
security = Security(app, user_datastore) security = Security(app, user_datastore, False)
app.register_blueprint(admin.Admin) app.register_blueprint(admin.Admin)
@app.context_processor
def func():
return {
"user_authenticated": current_user.is_authenticated,
"has_admin_access": current_user.has_permission("admin_site_access"),
"is_admin": current_user.has_role("admin"),
}
@app.route("/") @app.route("/")
def index(): def index():
return multiple_choice_category() return multiple_choice_category()
@ -58,6 +67,42 @@ def check_answer():
return jsonify(database.check_answer(question_id, answer)) return jsonify(database.check_answer(question_id, answer))
@app.route("/login", methods=["GET", "POST"])
def login():
next_page = request.args.get("next", default=url_for("index"))
if request.method == "POST":
email = request.form.get("email")
password = request.form.get("password")
remember = request.args.get("remember")
user = user_datastore.find_user(email=email)
if user is None or not user.check_password(password):
flash("invalid email or password")
return redirect(url_for("login"))
login_user(user, remember=remember)
return redirect(next_page)
return render_template("login.html", title="login")
@app.route("/register", methods=["GET", "POST"])
def register():
if request.method == "POST":
email = request.form.get("email")
password = request.form.get("password")
user = database.add_user(email, password)
if user is None:
flash("email already in use")
return redirect(url_for("register"))
login_user(user)
return redirect(url_for("index"))
return render_template("register.html")
@app.route("/logout")
def logout():
logout_user()
return redirect(url_for("index"))
@app.errorhandler(404) @app.errorhandler(404)
def error_404(e): def error_404(e):
print(e) print(e)

View File

@ -1,6 +1,6 @@
import os import os
from flask import current_app, g from flask import current_app, g
from flask_security import UserMixin, RoleMixin from flask_security import UserMixin, RoleMixin, SQLAlchemySessionUserDatastore, verify_password, hash_password
import sqlalchemy import sqlalchemy
from typing import Union, Optional, Literal, Type, List, Tuple from typing import Union, Optional, Literal, Type, List, Tuple
import random import random
@ -14,18 +14,19 @@ from werkzeug.utils import import_string
config = import_string(os.environ.get("CONFIGURATION_SETUP")) config = import_string(os.environ.get("CONFIGURATION_SETUP"))
engine = create_engine(config.DB_URL, pool_size=20) engine = create_engine(config.DB_URL, pool_size=20)
Base = declarative_base()
def get_scoped_session(): def get_scoped_session():
session_factory = sessionmaker(bind=engine) session_factory = sessionmaker(bind=engine)
Base.query = scoped_session(session_factory).query_property() # This is for compatibility with Flask-Security-Too which assumes usage of Flask-Sqlalchemy
return scoped_session(session_factory) return scoped_session(session_factory)
def get_session() -> sqlalchemy.orm.session.Session: def get_session() -> sqlalchemy.orm.session.Session:
if "session" not in g: if not hasattr(g, "session"):
Session = get_scoped_session() Session = get_scoped_session()
Session.query_property() Base.query = Session.query_property() # This is for compatibility with Flask-Security-Too which assumes usage of Flask-Sqlalchemy
# Session.query_property()
g.session = Session() g.session = Session()
return g.session return g.session
@ -41,7 +42,8 @@ def destroy_db():
Base.metadata.drop_all(engine) Base.metadata.drop_all(engine)
Base = declarative_base() def get_user_datastore() -> SQLAlchemySessionUserDatastore:
return SQLAlchemySessionUserDatastore(get_session(), User, Role)
class User(Base, UserMixin): class User(Base, UserMixin):
@ -59,6 +61,23 @@ class User(Base, UserMixin):
fs_uniquifier = Column(String, unique=True, nullable=False) fs_uniquifier = Column(String, unique=True, nullable=False)
roles = relationship("Role", secondary="users_roles") roles = relationship("Role", secondary="users_roles")
def check_password(self, password):
return verify_password(password, self.password)
def add_role(self, role):
self.roles.append(role)
def remove_role(self, role):
self.roles.remove(role)
def get_dict(self):
result = {
"user_id": self.user_id,
"email": self.email,
"username": self.username,
}
return result
class Role(Base, RoleMixin): class Role(Base, RoleMixin):
__tablename__ = "roles" __tablename__ = "roles"
@ -318,3 +337,67 @@ def update_question(question_id, data):
if column in HiddenAnswer.__table__.columns: if column in HiddenAnswer.__table__.columns:
setattr(question.hidden_answer, column, data[column]) setattr(question.hidden_answer, column, data[column])
session.commit() session.commit()
def add_user(email, password):
session = get_session()
user_datastore = get_user_datastore()
if user_datastore.find_user(email=email) is None:
user = user_datastore.create_user(email=email, password=hash_password(password), username=email)
session.add(user)
role = session.query(Role).filter(Role.name == "basic").one_or_none()
if role is not None:
user_datastore.add_role_to_user(user, role)
# user.add_role(role)
# user_datastore.commit()
# update_user(user.user_id, {"roles": ["basic"]})
# session.add(user)
# user_datastore.add_role_to_user(user, "basic")
session.commit()
return user
return None
def get_user(user_id) -> User:
session = get_session()
return session.query(User).filter(User.user_id == user_id).one_or_none()
def update_user(user_id, data):
session = get_session()
user_datastore = get_user_datastore()
user = session.query(User).filter(User.user_id == user_id).one_or_none()
for role in user.roles:
if role.name not in data["roles"]:
# user.remove_role(role)
user_datastore.remove_role_from_user(user, role)
for role_name in data["roles"]:
role = session.query(Role).filter(Role.name == role_name).one_or_none()
# if role is not None:
# user.add_role(role)
user_datastore.add_role_to_user(user, role)
for column in data.keys():
if column in User.__table__.columns:
setattr(user, column, data[column])
session.commit()
def query_users(offset, limit, query: dict = None, sort=None, order=None) -> Tuple[List[User], int]:
session = get_session()
query_params = []
if query is not None:
for key in query.keys():
if hasattr(User, key):
query_params.append(getattr(User, key).ilike("%" + query[key] + "%"))
order_by = None
if sort and order:
order_by = getattr(getattr(User, sort), order)()
q = session.query(User).filter(*query_params).order_by(order_by)
questions = q.offset(offset).limit(limit).all()
count = q.count()
return questions, count
def get_all_roles() -> List[Role]:
session = get_session()
return session.query(Role).all()

View File

@ -28,6 +28,17 @@
<a class="nav-link" href="{{ url_for('hidden_answer_category') }}">Hidden Answer</a> <a class="nav-link" href="{{ url_for('hidden_answer_category') }}">Hidden Answer</a>
</li> </li>
</ul> </ul>
<ul class="navbar-nav ms-auto">
{% if not user_authenticated %}
<li class="nav-item">
<a class="btn btn-primary" href="{{ url_for('login') }}">Login</a>
</li>
{% else %}
<li class="nav-item">
<a class="btn btn-secondary" href="{{ url_for('logout') }}">Logout</a>
</li>
{% endif %}
</ul>
</div> </div>
</div> </div>
</nav> </nav>

View File

@ -5,7 +5,6 @@
<div class="row justify-content-center tab-content"> <div class="row justify-content-center tab-content">
<div class="col-sm col-lg-3"> <div class="col-sm col-lg-3">
<form method="post"> <form method="post">
{{ login_user_form.hidden_tag() }}
<div class="form-group"> <div class="form-group">
<label for="email">Email</label> <label for="email">Email</label>
<input id="email" name="email" type="email" class="form-control"> <input id="email" name="email" type="email" class="form-control">
@ -20,6 +19,16 @@
</div> </div>
<button id="submit" name="submit" type="submit" class="btn btn-primary">Submit</button> <button id="submit" name="submit" type="submit" class="btn btn-primary">Submit</button>
</form> </form>
<div class="mt-3">
Don't have an account? <a href="{{ url_for('register') }}">Register</a>
</div>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="alert alert-danger mt-2" role="alert">
{{ messages[0] }}
</div>
{% endif %}
{% endwith %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,80 @@
{% extends "base.html" %}
{% block body %}
<div class="container-fluid mt-5">
<div class="row justify-content-center tab-content">
<div class="col-sm col-lg-3">
<form id="form" class="needs-validation" method="post">
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input id="email" name="email" type="email" class="form-control" required>
{# <div class="invalid-feedback">You must enter an email.</div>#}
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input id="password" name="password" type="password" class="form-control" required>
{# <div class="invalid-feedback">You must enter a password.</div>#}
</div>
<div class="mb-3">
<label for="password_confirm" class="form-label">Retype Password</label>
<input id="password_confirm" name="password_confirm" type="password" class="form-control" required>
{# <div class="invalid-feedback">You must confirm your password.</div>#}
</div>
<button id="submit" name="submit" type="submit" class="btn btn-primary">Register</button>
</form>
<div class="mt-3">
Already have an account? <a href="{{ url_for('login') }}">Login</a>
</div>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="alert alert-danger mt-2" role="alert">
{{ messages[0] }}
</div>
{% endif %}
{% endwith %}
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let form = $("#form");
let email = $("#email");
let password = $("#password");
let password_confirm = $("#password_confirm");
{#$("input[type='password']").attr("minlength", 4)#}
function password_validate() {
let password_val = password.val();
if (password_val.length < 4) {
return "Password must be at least 4 characters long!";
}
return "";
}
function password_confirm_validate() {
let password_val = password.val();
let password_confirm_val = password_confirm.val();
if (password_confirm_val !== password_val) {
return "passwords do not match!";
}
return "";
}
function form_valid() {
let password_result = password[0].setCustomValidity(password_validate());
let password_confirm_result = password_confirm[0].setCustomValidity(password_confirm_validate());
return form[0].checkValidity();
}
{#form.on("submit", (event) => {#}
{# if (!form_valid()) {#}
{# event.preventDefault();#}
{# event.stopPropagation()#}
{# }#}
{# form.addClass("was-validated");#}
{#})#}
</script>
{% endblock %}

View File

@ -1,26 +0,0 @@
{% extends "base.html" %}
{% block body %}
<div class="container-fluid mt-5">
<div class="row justify-content-center tab-content">
<div class="col-sm col-lg-3">
<form method="post">
{{ register_user_form.hidden_tag() }}
<div class="form-group">
<label for="email">Email</label>
<input id="email" name="email" type="email" class="form-control">
</div>
<div class="form-group">
<label for="password">Password</label>
<input id="password" name="password" type="password" class="form-control">
</div>
<div class="form-group">
<label for="password_confirm">Retype Password</label>
<input id="password_confirm" name="password_confirm" type="password" class="form-control">
</div>
<button id="submit" name="submit" type="submit" class="btn btn-primary">Register</button>
</form>
</div>
</div>
</div>
{% endblock %}