make all this have a login page, use ldap, put a logo on, moved some upstream stuff to static/ -- need to do more here to be consistent with bootstrap 5, but for another day

This commit is contained in:
2022-06-19 22:45:54 +10:00
parent 4725f006bc
commit c29f73f8ab
14 changed files with 274 additions and 9 deletions

121
main.py
View File

@@ -1,11 +1,16 @@
from flask import Flask, render_template, request, redirect, jsonify
from flask import Flask, render_template, request, redirect, jsonify, url_for
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.exc import SQLAlchemyError
from flask_marshmallow import Marshmallow
from flask_bootstrap import Bootstrap
from wtforms import SubmitField, StringField, HiddenField, SelectField, IntegerField, TextAreaField, validators
from flask_wtf import FlaskForm
from flask_compress import Compress
from status import st, Status
# for ldap auth
from flask_ldap3_login import LDAP3LoginManager
from flask_login import LoginManager, login_user, login_required, UserMixin, current_user, logout_user
from flask_ldap3_login.forms import LDAPLoginForm
import re
import os
@@ -21,10 +26,30 @@ else:
app.config['SQLALCHEMY_DATABASE_URI'] = DB_URL
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
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)
ma = Marshmallow(app)
Bootstrap(app)
# setup ldap for auth
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
# enable compression for http / speed
Compress(app)
################################# Now, import non-book classes ###################################
from author import Author, AuthorForm, AuthorSchema, GetAuthors
from publisher import Publisher, PublisherForm, PublisherSchema, GetPublisherById
@@ -35,6 +60,7 @@ from owned import Owned, OwnedForm, OwnedSchema, GetOwnedById
from rating import Rating, RatingForm, RatingSchema, GetRatingById
from loan import Loan, LoanForm, LoanSchema
from series import Series, SeriesForm, SeriesSchema, ListOfSeriesWithMissingBooks, CalcAvgRating
from user import BDBUser
####################################### CLASSES / DB model #######################################
class QuickParentBook:
@@ -246,12 +272,67 @@ app.jinja_env.globals['GetRatingById'] = GetRatingById
app.jinja_env.globals['SeriesBookNum'] = SeriesBookNum
app.jinja_env.globals['ClearStatus'] = st.ClearStatus
app.jinja_env.globals['ListOfSeriesWithMissingBooks'] = ListOfSeriesWithMissingBooks
app.jinja_env.globals['current_user'] = current_user
book_schema = BookSchema()
books_schema = BookSchema(many=True)
# Declare a User Loader for Flask-Login.
# Returns the User if it exists in our 'database', otherwise returns None.
@login_manager.user_loader
def load_user(id):
bdbu=BDBUser.query.filter(BDBUser.dn==id).first()
return bdbu
# Declare The User Saver for Flask-Ldap3-Login
# This method is called whenever a LDAPLoginForm() successfully validates.
# store the user details / session in the DB if it is not in there already
@ldap_manager.save_user
def save_user(dn, username, data, memberships):
bdbu=BDBUser.query.filter(BDBUser.dn==dn).first()
# if we already have a valid user/session, and say the web has restarted, just re-use it, dont make more users
if bdbu:
return bdbu
bdbu=BDBUser(dn=dn)
db.session.add(bdbu)
db.session.commit()
return bdbu
# POST is when user submits pwd & uses flask-login to hit ldap, validate pwd
# if valid, then we save user/session into the DB via login_user() -> calls save_user()
@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"
# the re matches on any special LDAP chars, we dont want someone
# ldap-injecting our username, so send them back to the login page instead
if request.method == 'POST' and re.search( r'[()\\*&!]', request.form['username']):
print( f"WARNING: Detected special LDAP chars in username: {request.form['username']}")
return redirect(url_for('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( url_for('main_page') )
return render_template("login.html", form=form)
@app.route('/logout')
@login_required
def logout():
logout_user()
return redirect('/login')
####################################### ROUTES #######################################
@app.route("/search", methods=["POST"])
@login_required
def search():
if 'InDBox' in request.form:
# removes already loaned books from list of books to loan out
@@ -264,12 +345,14 @@ def search():
return render_template("books.html", books=books, InDBox=InDBox)
@app.route("/books", methods=["GET"])
@login_required
def books():
books = Book.query.all()
AddSubs(books)
return render_template("books.html", books=books )
@app.route("/books_for_loan/<id>", methods=["GET", "POST"])
@login_required
def books_for_loan(id):
books = Book.query.join(Book_Loan_Link).filter(Book_Loan_Link.loan_id==id).order_by(Book.id).all()
if request.method == 'POST':
@@ -332,6 +415,7 @@ def CalcMoveForBookInSeries( id, bid, dir ):
book2.MoveBookInSeries( id, b2_move_by )
@app.route("/books_for_series/<id>", methods=["GET", "POST"])
@login_required
def books_for_series(id):
if request.method == 'POST':
if 'move_button' in request.form:
@@ -346,6 +430,7 @@ def books_for_series(id):
return render_template("books_for_series.html", books=books, series=series)
@app.route("/subbooks_for_book/<id>", methods=["GET", "POST"])
@login_required
def subbooks_for_book(id):
if request.method == 'POST':
if 'move_button' in request.form:
@@ -389,6 +474,7 @@ def subbooks_for_book(id):
# /books (or /book/<parent_id> -- if you added a sub-book of parent_id
################################################################################
@app.route("/remove_subbook", methods=["POST"])
@login_required
def remove_sub_book():
try:
db.engine.execute("delete from book_sub_book_link where book_id = {} and sub_book_id = {}".format( request.form['rem_sub_parent_id'], request.form['rem_sub_sub_book_id']) )
@@ -404,6 +490,7 @@ def remove_sub_book():
# /books (or /book/<parent_id> -- if you added a sub-book of parent_id
################################################################################
@app.route("/book", methods=["GET", "POST"])
@login_required
def new_book():
form = BookForm(request.form)
page_title='Create new Book'
@@ -475,6 +562,7 @@ def new_book():
@app.route("/book/<id>", methods=["GET", "POST"])
@login_required
def book(id):
book_form = BookForm(request.form)
page_title='Edit Book'
@@ -619,6 +707,7 @@ def GetCount( what, where ):
return rtn
@app.route("/stats", methods=["GET"])
@login_required
def stats():
stats=[]
@@ -637,6 +726,7 @@ def stats():
return render_template("stats.html", stats=stats )
@app.route("/rem_books_from_loan/<id>", methods=["POST"])
@login_required
def rem_books_from_loan(id):
for field in request.form:
rem_id=int(re.findall( '\d+', field )[0])
@@ -649,6 +739,7 @@ def rem_books_from_loan(id):
return jsonify(success=True)
@app.route("/add_books_to_loan/<id>", methods=["POST"])
@login_required
def add_books_to_loan(id):
for field in request.form:
add_id=int(re.findall( '\d+', field )[0])
@@ -662,6 +753,7 @@ def add_books_to_loan(id):
return jsonify(success=True)
@app.route("/rem_parent_books_from_series/<pid>", methods=["POST"])
@login_required
def rem_parent_books_from_series(pid):
print ("pid={}".format(pid) )
try:
@@ -674,12 +766,14 @@ def rem_parent_books_from_series(pid):
return jsonify(success=True)
@app.route("/books_on_shelf", methods=["GET"])
@login_required
def books_on_shelf():
books = Book.query.join(Owned).filter(Owned.name=='Currently Owned').all()
RemSubs(books)
return render_template("books.html", books=books, page_title="Books on Shelf", order_by="Author(s)", show_cols='', hide_cols='' )
@app.route("/unrated_books", methods=["GET"])
@login_required
def unrated_books():
books = Book.query.join(Condition,Owned).filter(Rating.name=='Undefined',Owned.name=='Currently Owned').all()
return render_template("books.html", books=books, page_title="Books with no rating", show_cols='Rating', hide_cols='' )
@@ -710,39 +804,50 @@ def FindMissingBooks():
return books
@app.route("/missing_books", methods=["GET"])
@login_required
def missing_books():
books=FindMissingBooks()
return render_template("missing.html", books=books )
@app.route("/wishlist", methods=["GET"])
@login_required
def wishlist():
books = Book.query.join(Owned).filter(Owned.name=='On Wish List').all()
return render_template("books.html", books=books, page_title="Books On Wish List", show_cols='', hide_cols='Publisher,Condition,Covertype' )
@app.route("/books_to_buy", methods=["GET"])
@login_required
def books_to_buy():
books = Book.query.join(Owned).filter(Owned.name=='On Wish List').all()
missing = FindMissingBooks()
return render_template("to_buy.html", books=books, missing=missing, page_title="Books To Buy")
@app.route("/needs_replacing", methods=["GET"])
@login_required
def needs_replacing():
books = Book.query.join(Condition,Owned).filter(Condition.name=='Needs Replacing',Owned.name=='Currently Owned').all()
return render_template("books.html", books=books, page_title="Books that Need Replacing", show_cols='', hide_cols='' )
@app.route("/sold", methods=["GET"])
@login_required
def sold_books():
books = Book.query.join(Owned).filter(Owned.name=='Sold').all()
return render_template("books.html", books=books, page_title="Books that were Sold", show_cols='', hide_cols='' )
@app.route("/poor_rating", methods=["GET"])
@login_required
def poor_rating_books():
books = Book.query.join(Rating,Owned).filter(Rating.id>6,Rating.name!='Undefined',Owned.name=='Currently Owned').all()
return render_template("books.html", books=books, page_title="Books that have a Poor Rating (<5 out of 10)", show_cols='Rating', hide_cols='' )
# default page, just the navbar
@app.route("/", methods=["GET"])
@login_required
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", alert=st.GetAlert(), message=st.GetMessage())
if __name__ == "__main__":
@@ -750,3 +855,17 @@ if __name__ == "__main__":
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)
else:
app.run(host="0.0.0.0", debug=True)
###############################################################################
# This func creates a new filter in jinja2 to test to hand back the username
# from the ldap dn
################################################################################
@app.template_filter('Username')
def _jinja2_filter_parentpath(dn):
# pull apart a dn (uid=xxx,dn=yyy,etc), and return the xxx
username=str(dn)
s=username.index('=')
s+=1
f=username.index(',')
return username[s:f]