From bdabc9f32ae2370ccafc1617f32ab8da0c3542e5 Mon Sep 17 00:00:00 2001 From: Mo8it Date: Tue, 9 Aug 2022 14:46:48 +0200 Subject: [PATCH] Use app factory pattern --- advlabdb/__init__.py | 76 ++++++++++++++++-------------- advlabdb/adminModelViews.py | 66 ++++++++++++++++---------- advlabdb/assistantModelViews.py | 33 +++++++++---- advlabdb/config.py | 15 ++++-- advlabdb/custom_classes.py | 5 +- advlabdb/database_import.py | 5 +- advlabdb/model_dependent_funs.py | 4 +- advlabdb/model_independent_funs.py | 14 +----- advlabdb/models.py | 19 ++++++-- advlabdb/routes.py | 16 ++++--- advlabdb/templates/macros.html | 34 ++++++------- docs/server_setup.adoc | 2 +- manage.py | 17 +++---- podman/deploy.py | 2 +- run.py | 3 +- 15 files changed, 179 insertions(+), 132 deletions(-) diff --git a/advlabdb/__init__.py b/advlabdb/__init__.py index 7838cd0..b626b48 100644 --- a/advlabdb/__init__.py +++ b/advlabdb/__init__.py @@ -1,47 +1,51 @@ -from os import environ - -from flask import Flask -from flask_admin import Admin -from flask_security import Security, SQLAlchemyUserDatastore -from flask_security.models import fsqla_v2 as fsqla -from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate +from flask_security import SQLAlchemyUserDatastore -from .config import set_config +from .config import get_settings +from .models import db, User, Role -app = Flask(__name__) +migrate = Migrate() -# Config -settings = set_config(app) +settings = get_settings() -# Setup Flask-SQLAlchemy -db = SQLAlchemy(app) -fsqla.FsModels.set_db_info(db) +user_datastore = SQLAlchemyUserDatastore(db, User, Role) -# Setup Flask-Migrate -migrate = Migrate(app, db) -# Setup Flask-Admin -from .custom_classes import SecureAdminIndexView, SecureAssistantIndexView +def create_app(create_for_server=True): + from flask import Flask -adminSpace = Admin( - app, - name="Admin@AdvLabDB", - url="/admin", - template_mode="bootstrap3", - index_view=SecureAdminIndexView(name="Home", url="/admin", endpoint="admin"), -) -assistantSpace = Admin( - app, - name="Assistant@AdvLabDB", - url="/assistant", - template_mode="bootstrap3", - index_view=SecureAssistantIndexView(name="Home", url="/assistant", endpoint="assistant"), -) + app = Flask(__name__) -from . import models + # Config + from .config import set_config -user_datastore = SQLAlchemyUserDatastore(db, models.User, models.Role) -Security(app, user_datastore) + set_config(app) -from . import routes, adminModelViews, assistantModelViews + # Setup Flask-SQLAlchemy + + db.init_app(app) + + # Setup Flask-Migrate + migrate.init_app(app, db) + + # Setup Flask-Security-Too + from flask_security import Security + + Security(app, user_datastore) + + if create_for_server: + # Setup views + from .adminModelViews import init_admin_model_views + + init_admin_model_views(app) + + from .assistantModelViews import init_assistant_model_views + + init_assistant_model_views(app) + + # Register blueprints + from .routes import bp as routes_bp + + app.register_blueprint(routes_bp) + + return app diff --git a/advlabdb/adminModelViews.py b/advlabdb/adminModelViews.py index 4942f8f..2e42733 100644 --- a/advlabdb/adminModelViews.py +++ b/advlabdb/adminModelViews.py @@ -4,6 +4,7 @@ from pathlib import Path import numpy as np from flask import flash, has_request_context, redirect, url_for +from flask_admin import Admin as FlaskAdmin from flask_admin import expose from flask_admin.contrib.sqla.fields import QuerySelectField, QuerySelectMultipleField from flask_admin.contrib.sqla.filters import BooleanEqualFilter, FilterEqual @@ -28,7 +29,7 @@ from wtforms.fields import ( from wtforms.validators import URL, DataRequired, Email, NumberRange, Optional from wtforms.widgets import NumberInput -from . import adminSpace, assistantSpace, db, user_datastore +from . import user_datastore from .admin_link_formatters import ( admin_formatter, appointment_date_formatter, @@ -56,7 +57,12 @@ from .advlabdb_independent_funs import ( flashRandomPassword, str_without_semester_formatter, ) -from .custom_classes import SecureAdminBaseView, SecureAdminModelView +from .assistantModelViews import assistantSpace +from .custom_classes import ( + SecureAdminBaseView, + SecureAdminIndexView, + SecureAdminModelView, +) from .database_import import importFromFile from .exceptions import ModelViewException from .model_dependent_funs import ( @@ -86,6 +92,15 @@ from .models import ( SemesterExperiment, Student, User, + db, +) + +adminSpace = FlaskAdmin( + name="Admin@AdvLabDB", + url="/admin", + template_mode="bootstrap4", + static_url_path="/static/a", + index_view=SecureAdminIndexView(name="Home", url="/admin", endpoint="admin"), ) @@ -356,7 +371,7 @@ class SemesterView(SecureAdminModelView): categoryText = "Active semester" link = MenuLink( name=str(newSemester), - url=url_for("set_semester") + "?semester_id=" + str(newSemester.id), + url=url_for("main.set_semester") + "?semester_id=" + str(newSemester.id), category=categoryText, ) @@ -1282,7 +1297,7 @@ class ImportView(SecureAdminBaseView): except Exception as ex: flash(str(ex), "error") - return redirect(url_for("index")) + return redirect(url_for("main.index")) return self.render("import.html", form=form) @@ -1304,7 +1319,7 @@ class ActionsView(SecureAdminBaseView): flash("Manually updated all final experiment and part marks", "success") - return redirect(url_for("index")) + return redirect(url_for("main.index")) return self.render("actions.html", form=form) @@ -1469,23 +1484,26 @@ class DocsView(SecureAdminBaseView): return self.render("docs/docs.html", role="admin") -adminSpace.add_view(StudentView(Student, url="student")) -adminSpace.add_view(PartStudentView(PartStudent, url="part_student")) -adminSpace.add_view(GroupView(Group, url="group")) -adminSpace.add_view(GroupExperimentView(GroupExperiment, url="group_experiment")) -adminSpace.add_view(AppointmentView(Appointment, url="appointment")) -adminSpace.add_view(ExperimentMarkView(ExperimentMark, url="experiment_mark")) -adminSpace.add_view(ExperimentView(Experiment, url="experiment")) -adminSpace.add_view(SemesterExperimentView(SemesterExperiment, url="semester_experiment")) -adminSpace.add_view(SemesterView(Semester, url="semester")) -adminSpace.add_view(PartView(Part, url="part")) -adminSpace.add_view(AssistantView(Assistant, url="assistant")) -adminSpace.add_view(AdminView(Admin, url="admin")) -adminSpace.add_view(UserView(User, url="user")) -adminSpace.add_view(ProgramView(Program, url="program")) -adminSpace.add_view(ImportView(name="Import", url="import")) -adminSpace.add_view(ActionsView(name="Actions", url="actions")) -adminSpace.add_view(AnalysisView(name="Analysis", url="analysis")) -adminSpace.add_view(DocsView(name="Docs", url="docs")) +def init_admin_model_views(app): + adminSpace.init_app(app) -initActiveSemesterMenuLinks(adminSpace) + adminSpace.add_view(StudentView(Student, url="student")) + adminSpace.add_view(PartStudentView(PartStudent, url="part_student")) + adminSpace.add_view(GroupView(Group, url="group")) + adminSpace.add_view(GroupExperimentView(GroupExperiment, url="group_experiment")) + adminSpace.add_view(AppointmentView(Appointment, url="appointment")) + adminSpace.add_view(ExperimentMarkView(ExperimentMark, url="experiment_mark")) + adminSpace.add_view(ExperimentView(Experiment, url="experiment")) + adminSpace.add_view(SemesterExperimentView(SemesterExperiment, url="semester_experiment")) + adminSpace.add_view(SemesterView(Semester, url="semester")) + adminSpace.add_view(PartView(Part, url="part")) + adminSpace.add_view(AssistantView(Assistant, url="assistant")) + adminSpace.add_view(AdminView(Admin, url="admin")) + adminSpace.add_view(UserView(User, url="user")) + adminSpace.add_view(ProgramView(Program, url="program")) + adminSpace.add_view(ImportView(name="Import", url="import")) + adminSpace.add_view(ActionsView(name="Actions", url="actions")) + adminSpace.add_view(AnalysisView(name="Analysis", url="analysis")) + adminSpace.add_view(DocsView(name="Docs", url="docs")) + + initActiveSemesterMenuLinks(adminSpace, app) diff --git a/advlabdb/assistantModelViews.py b/advlabdb/assistantModelViews.py index 29aacf7..9ca3c9a 100644 --- a/advlabdb/assistantModelViews.py +++ b/advlabdb/assistantModelViews.py @@ -1,17 +1,21 @@ from flask import flash, redirect, request, url_for +from flask_admin import Admin as FlaskAdmin from flask_admin import expose from flask_admin.model.template import EndpointLinkRowAction from flask_security import admin_change_password, current_user from flask_wtf import FlaskForm -from . import assistantSpace, db from .advlabdb_independent_funs import ( deep_getattr, experiment_marks_missing_formatter, flashRandomPassword, str_formatter, ) -from .custom_classes import SecureAssistantBaseView, SecureAssistantModelView +from .custom_classes import ( + SecureAssistantBaseView, + SecureAssistantIndexView, + SecureAssistantModelView, +) from .exceptions import ModelViewException from .forms import assistant_group_experiment_form_factory from .model_dependent_funs import ( @@ -21,7 +25,15 @@ from .model_dependent_funs import ( user_info_fields, ) from .model_independent_funs import randomPassword, reportBadAttempt -from .models import Assistant, GroupExperiment, SemesterExperiment, User +from .models import Assistant, GroupExperiment, SemesterExperiment, User, db + +assistantSpace = FlaskAdmin( + name="Assistant@AdvLabDB", + url="/assistant", + template_mode="bootstrap4", + static_url_path="/static/a", + index_view=SecureAssistantIndexView(name="Home", url="/assistant", endpoint="assistant"), +) class AssistantGroupExperimentView(SecureAssistantModelView): @@ -89,7 +101,7 @@ class AssistantGroupExperimentView(SecureAssistantModelView): try: group_experiment = db.session.get(GroupExperiment, int(group_experiment_id_str)) except Exception: - red = url_for("index") + "assistant/group_experiment" + red = url_for("main.index") + "assistant/group_experiment" flash("No valid group experiment id") return redirect(red) @@ -146,7 +158,7 @@ class AssistantGroupExperimentView(SecureAssistantModelView): db.session.commit() - red = url_for("index") + "assistant/group_experiment" + red = url_for("main.index") + "assistant/group_experiment" return redirect(red) except Exception as ex: flash(str(ex), "error") @@ -211,8 +223,11 @@ class AssistantDocsView(SecureAssistantBaseView): return self.render("docs/docs.html", role="assistant") -assistantSpace.add_view(AssistantGroupExperimentView(GroupExperiment, url="group_experiment")) -assistantSpace.add_view(AssistantUserView(User, url="user")) -assistantSpace.add_view(AssistantDocsView(name="Docs", url="docs")) +def init_assistant_model_views(app): + assistantSpace.init_app(app) -initActiveSemesterMenuLinks(assistantSpace) + assistantSpace.add_view(AssistantGroupExperimentView(GroupExperiment, url="group_experiment")) + assistantSpace.add_view(AssistantUserView(User, url="user")) + assistantSpace.add_view(AssistantDocsView(name="Docs", url="docs")) + + initActiveSemesterMenuLinks(assistantSpace, app) diff --git a/advlabdb/config.py b/advlabdb/config.py index 493968d..8bc70e8 100644 --- a/advlabdb/config.py +++ b/advlabdb/config.py @@ -3,14 +3,14 @@ from configparser import ConfigParser from pathlib import Path -def load_config(app, *files): +def load_config(*files): config = ConfigParser() for file in files: file = Path(file) if not file.is_file(): - app.logger.critical(str(file) + " is missing") + print(f"{file} is missing!") sys.exit(1) config.read(file) @@ -18,8 +18,15 @@ def load_config(app, *files): return config +def get_settings(): + config = load_config("settings.ini") + settings = config["Settings"] + + return settings + + def set_config(app): - config = load_config(app, "secrets.ini", "settings.ini") + config = load_config("secrets.ini", "settings.ini") secrets = config["Secrets"] settings = config["Settings"] @@ -55,5 +62,3 @@ def set_config(app): app.config["SECURITY_PASSWORD_SALT"] = secrets["SECURITY_PASSWORD_SALT"] app.config["SECURITY_PASSWORD_LENGTH_MIN"] = settings.getint("SECURITY_PASSWORD_LENGTH_MIN", 15) # TODO: app.config["SECURITY_LOGIN_USER_TEMPLATE"] = - - return settings diff --git a/advlabdb/custom_classes.py b/advlabdb/custom_classes.py index 7c336e6..78f19db 100644 --- a/advlabdb/custom_classes.py +++ b/advlabdb/custom_classes.py @@ -6,9 +6,8 @@ from flask_admin.model.helpers import get_mdict_item_or_list from flask_security import current_user from sqlalchemy import select -from . import db from .exceptions import DataBaseException, ModelViewException -from .model_independent_funs import get_count, reportBadAttempt +from .model_independent_funs import reportBadAttempt from .models import ( Assistant, ExperimentMark, @@ -16,6 +15,8 @@ from .models import ( Part, PartStudent, SemesterExperiment, + db, + get_count, ) diff --git a/advlabdb/database_import.py b/advlabdb/database_import.py index aed03e5..fd20c83 100644 --- a/advlabdb/database_import.py +++ b/advlabdb/database_import.py @@ -5,9 +5,8 @@ from shutil import copy2 from flask import flash, has_request_context from sqlalchemy import select -from . import db, settings +from . import settings from .exceptions import DataBaseImportException -from .model_independent_funs import get_first from .models import ( Appointment, Assistant, @@ -21,6 +20,8 @@ from .models import ( SemesterExperiment, Student, User, + db, + get_first, ) diff --git a/advlabdb/model_dependent_funs.py b/advlabdb/model_dependent_funs.py index cf84142..4a940e7 100644 --- a/advlabdb/model_dependent_funs.py +++ b/advlabdb/model_dependent_funs.py @@ -10,11 +10,11 @@ from flask_security import current_user from wtforms.fields import BooleanField, IntegerField, SelectField, StringField from wtforms.validators import DataRequired, NumberRange, Optional -from . import app, settings +from . import settings from .models import MAX_MARK, MIN_MARK, Semester -def initActiveSemesterMenuLinks(space): +def initActiveSemesterMenuLinks(space, app): with app.app_context(): try: semesters = Semester.sortedSemestersStartingWithNewest() diff --git a/advlabdb/model_independent_funs.py b/advlabdb/model_independent_funs.py index 689573e..f47d282 100644 --- a/advlabdb/model_independent_funs.py +++ b/advlabdb/model_independent_funs.py @@ -5,25 +5,15 @@ Functions not dependent on advlabdb.models. import secrets from string import ascii_letters, digits -from sqlalchemy import func, select - -from . import app, db +from flask import current_app PASSWORD_CHARS: str = ascii_letters + digits + "!%*+=?" def randomPassword() -> str: - password_length = app.config["SECURITY_PASSWORD_LENGTH_MIN"] + password_length = current_app.config["SECURITY_PASSWORD_LENGTH_MIN"] return "".join(secrets.choice(PASSWORD_CHARS) for i in range(password_length)) def reportBadAttempt(message: str) -> None: print("BAD ATTEMPT:", message) # TODO: Log - - -def get_count(table): - return db.session.scalar(select(func.count()).select_from(table)) - - -def get_first(table): - return db.session.execute(table.limit(1)).scalars().first() diff --git a/advlabdb/models.py b/advlabdb/models.py index ed9e682..da35656 100644 --- a/advlabdb/models.py +++ b/advlabdb/models.py @@ -10,12 +10,12 @@ from decimal import ROUND_HALF_UP, Decimal from flask import flash from flask_security import current_user +from flask_security.models import fsqla_v2 as fsqla from flask_security.models.fsqla_v2 import FsRoleMixin, FsUserMixin -from sqlalchemy import select +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import func, select -from . import db from .exceptions import DataBaseException -from .model_independent_funs import get_first MIN_MARK = 0 MAX_MARK = 15 @@ -27,6 +27,19 @@ MIN_GROUP_NUMBER = 1 MIN_DURATION_IN_DAYS = 1 MIN_PART_NUMBER = 1 +db = SQLAlchemy() + +# For Flask-Security-Too +fsqla.FsModels.set_db_info(db) + + +def get_count(table): + return db.session.scalar(select(func.count()).select_from(table)) + + +def get_first(table): + return db.session.execute(table.limit(1)).scalars().first() + def roundHalfUpToInt(number): return int(Decimal(number).quantize(Decimal(0), rounding=ROUND_HALF_UP)) diff --git a/advlabdb/routes.py b/advlabdb/routes.py index 2436680..86635a9 100644 --- a/advlabdb/routes.py +++ b/advlabdb/routes.py @@ -1,19 +1,21 @@ -from flask import flash, redirect, request, url_for +from flask import Blueprint, flash, redirect, request, url_for from flask_security import auth_required, current_user -from . import app, db from .model_dependent_funs import active_semester_str -from .models import Semester +from .models import Semester, db + +bp = Blueprint("main", __name__, root_path="/", template_folder="templates") -@app.context_processor +@bp.app_context_processor def util_processor(): author_email = "mobitar@students.uni-mainz.de" footer = f"

This website is still under development (beta release)! If you have any questions, find any bugs or want some feature, please write a formless email (german/english) to Mo Bitar: {author_email}. Feedback is also welcome :)


" + return dict(active_semester_str=active_semester_str, current_user=current_user, footer=footer) -@app.route("/") +@bp.route("/") def index(): if current_user.has_role("admin"): endpoint_base = "admin" @@ -30,7 +32,7 @@ def index(): return redirect(url) -@app.route("/set_semester") +@bp.route("/set_semester") @auth_required() def set_semester(): try: @@ -41,5 +43,5 @@ def set_semester(): semester = db.session.get(Semester, semesterId) current_user.setActiveSemester(semester) - red = request.referrer or url_for("index") + red = request.referrer or url_for("main.index") return redirect(red) diff --git a/advlabdb/templates/macros.html b/advlabdb/templates/macros.html index e4262af..4ee3e95 100644 --- a/advlabdb/templates/macros.html +++ b/advlabdb/templates/macros.html @@ -1,18 +1,20 @@ -{% macro information(current_user, active_semester_str, role) %} - User: {{ current_user }} +{% macro information(current_user, active_semester_str, role) %} User: +{{ current_user }} - | Active semester: {{ active_semester_str() }} - - {% if (role == "admin") and (current_user.has_role("assistant")) %} - | Assistant space. - {% elif (role == "assistant") and (current_user.has_role("admin")) %} - | Admin space. - {% endif %} -{% endmacro %} - -{% macro missing_final_experiment_marks(number_of_missing_final_experiment_marks, number_of_all_experiment_marks) %} -

- Number of missing final experiment marks: - {{ number_of_missing_final_experiment_marks }} / {{ number_of_all_experiment_marks }} -

+| Active semester: {{ active_semester_str() }} {% if (role == "admin") and +(current_user.has_role("assistant")) %} | +Assistant space. {% elif +(role == "assistant") and (current_user.has_role("admin")) %} | +Admin space. {% endif %} {% +endmacro %} {% macro +missing_final_experiment_marks(number_of_missing_final_experiment_marks, +number_of_all_experiment_marks) %} +

+ Number of missing final experiment marks: {{ + number_of_missing_final_experiment_marks }} / {{ + number_of_all_experiment_marks }} +

{% endmacro %} diff --git a/docs/server_setup.adoc b/docs/server_setup.adoc index e5621ee..3081f2d 100644 --- a/docs/server_setup.adoc +++ b/docs/server_setup.adoc @@ -117,7 +117,7 @@ cd ~/advlabdb + [source,bash] ---- -poetry run python3 manage.py setup initialize-database +poetry run python3 manage.py setup init-db ---- .. *Done!* Now go to your SERVER_NAME using a browser to verify that everything is working. diff --git a/manage.py b/manage.py index 4010bb5..4ed0bbc 100755 --- a/manage.py +++ b/manage.py @@ -12,9 +12,9 @@ from flask_admin import __file__ as flask_admin_path from flask_security import admin_change_password, hash_password from sqlalchemy import select -from advlabdb import app, db, settings, user_datastore +from advlabdb import create_app, settings, user_datastore from advlabdb.model_independent_funs import randomPassword -from advlabdb.models import MAX_YEAR, MIN_YEAR, Admin, Semester, User +from advlabdb.models import MAX_YEAR, MIN_YEAR, Admin, Semester, User, db def run(command: str, **kwargs): @@ -76,23 +76,16 @@ def generate_secrets(): short_help="Initialize the database.", help="Initialize the database if it does not already exist.", ) -def initialize_database(): +def init_db(): db_file = Path(settings["SQLITE_DB_PATH"]) if db_file.is_file(): click.echo(f"Skipping database initialization because the database does already exist at {db_file}.") return - click.echo("\nThis script should only be used to initialize the database after setting up a server") - click.echo(click.style("The old database will be DELETED and a new database will be created!", bg="red")) - - if not click.confirm(click.style("Are you sure that you want to continue?", fg="red"), default=False): - click.echo(click.style("Aborted!", fg="yellow")) - return + app = create_app(create_for_server=False) with app.app_context(): with db.session.begin(): - # Delete old database - db.drop_all() # Create new database db.create_all() @@ -170,6 +163,8 @@ def maintain(): def reset_admin_password(): click.echo("This script will generate a new random password for a chosen admin.\n") + app = create_app(create_for_server=False) + with app.app_context(): with db.session.begin(): admins = db.session.execute(select(Admin).join(User).where(User.active == True)).scalars().all() diff --git a/podman/deploy.py b/podman/deploy.py index 8b158ee..4d48752 100755 --- a/podman/deploy.py +++ b/podman/deploy.py @@ -167,7 +167,7 @@ commands = [ # Install Python requirements in the container "buildah run builder -- pip3 install -r requirements.txt", "buildah run builder -- python3 manage.py setup generate-secrets", - "buildah run builder -- python3 manage.py setup initialize-database", + "buildah run builder -- python3 manage.py setup init-db", "buildah config --cmd 'gunicorn --bind 0.0.0.0:80 --workers 5 --log-file /volumes/logs/gunicorn.log run:app' builder", ] diff --git a/run.py b/run.py index 15f4385..4cb09e6 100644 --- a/run.py +++ b/run.py @@ -1,4 +1,5 @@ -from advlabdb import app +from advlabdb import create_app if __name__ == "__main__": + app = create_app() app.run(debug=True)