1
0
Fork 0
mirror of https://codeberg.org/Mo8it/AdvLabDB.git synced 2024-12-02 22:33:05 +00:00

Use app factory pattern

This commit is contained in:
Mo 2022-08-09 14:46:48 +02:00
parent e1e9c0e42e
commit bdabc9f32a
15 changed files with 179 additions and 132 deletions

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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()

View file

@ -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()

View file

@ -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))

View file

@ -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"<hr><p style='font-size:14px;'>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: <a href='mailto:{author_email}'>{author_email}</a>. Feedback is also welcome :)</p><br>"
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)

View file

@ -1,18 +1,20 @@
{% macro information(current_user, active_semester_str, role) %}
User: <a href="{{ url_for('index') }}{{ role }}/user/details/?id={{ current_user.id }}">{{ current_user }}</a>
{% macro information(current_user, active_semester_str, role) %} User:
<a
href="{{ url_for('main.index') }}{{ role }}/user/details/?id={{ current_user.id }}"
>{{ current_user }}</a
>
| Active semester: {{ active_semester_str() }}
{% if (role == "admin") and (current_user.has_role("assistant")) %}
| <a href="{{ url_for('index') }}assistant">Assistant space</a>.
{% elif (role == "assistant") and (current_user.has_role("admin")) %}
| <a href="{{ url_for('index') }}admin">Admin space</a>.
{% endif %}
{% endmacro %}
{% macro missing_final_experiment_marks(number_of_missing_final_experiment_marks, number_of_all_experiment_marks) %}
<p>
Number of <strong>missing</strong> final experiment marks:
{{ number_of_missing_final_experiment_marks }} / {{ number_of_all_experiment_marks }}
</p>
| Active semester: {{ active_semester_str() }} {% if (role == "admin") and
(current_user.has_role("assistant")) %} |
<a href="{{ url_for('main.index') }}assistant">Assistant space</a>. {% elif
(role == "assistant") and (current_user.has_role("admin")) %} |
<a href="{{ url_for('main.index') }}admin">Admin space</a>. {% endif %} {%
endmacro %} {% macro
missing_final_experiment_marks(number_of_missing_final_experiment_marks,
number_of_all_experiment_marks) %}
<p>
Number of <strong>missing</strong> final experiment marks: {{
number_of_missing_final_experiment_marks }} / {{
number_of_all_experiment_marks }}
</p>
{% endmacro %}

View file

@ -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.

View file

@ -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()

View file

@ -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",
]

3
run.py
View file

@ -1,4 +1,5 @@
from advlabdb import app
from advlabdb import create_app
if __name__ == "__main__":
app = create_app()
app.run(debug=True)