From d4cd3139402a5cd29fac51a4dda72f56a7657b05 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Mon, 16 Dec 2024 15:37:19 +0100 Subject: [PATCH] Implement /admin using flask-admin. Overall cleanup --- app/__init__.py | 11 +- app/blueprints/admin/__init__.py | 20 --- app/blueprints/admin/forms.py | 16 --- app/blueprints/admin/json_routes.py | 23 --- app/blueprints/admin/routes.py | 136 ------------------ app/extensions/nopaque_flask_admin_views.py | 20 +++ .../nopaque_sqlalchemy_extras/__init__.py | 2 - ... => nopaque_sqlalchemy_type_decorators.py} | 0 app/models/__init__.py | 59 ++++++-- app/models/corpus.py | 2 +- app/models/job.py | 2 +- app/models/spacy_nlp_pipeline_model.py | 2 +- app/models/tesseract_ocr_pipeline_model.py | 2 +- app/models/user.py | 2 +- app/templates/_base/dropdowns.html.j2 | 4 +- app/templates/_base/sidenav.html.j2 | 4 +- app/templates/admin/admin.html.j2 | 43 ------ app/templates/admin/corpora.html.j2 | 29 ---- app/templates/admin/edit_user.html.j2 | 104 -------------- app/templates/admin/user.html.j2 | 131 ----------------- app/templates/admin/user_settings.html.j2 | 66 --------- app/templates/admin/users.html.j2 | 34 ----- app/templates/settings/index.html.j2 | 2 - requirements.freezed.txt | 1 + requirements.txt | 1 + 25 files changed, 84 insertions(+), 632 deletions(-) delete mode 100644 app/blueprints/admin/__init__.py delete mode 100644 app/blueprints/admin/forms.py delete mode 100644 app/blueprints/admin/json_routes.py delete mode 100644 app/blueprints/admin/routes.py create mode 100644 app/extensions/nopaque_flask_admin_views.py delete mode 100644 app/extensions/nopaque_sqlalchemy_extras/__init__.py rename app/extensions/{nopaque_sqlalchemy_extras/types.py => nopaque_sqlalchemy_type_decorators.py} (100%) delete mode 100644 app/templates/admin/admin.html.j2 delete mode 100644 app/templates/admin/corpora.html.j2 delete mode 100644 app/templates/admin/edit_user.html.j2 delete mode 100644 app/templates/admin/user.html.j2 delete mode 100644 app/templates/admin/user_settings.html.j2 delete mode 100644 app/templates/admin/users.html.j2 diff --git a/app/__init__.py b/app/__init__.py index eb4f7bd6..3b347e84 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -3,6 +3,7 @@ from config import Config from docker import DockerClient from flask import Flask from flask.logging import default_handler +from flask_admin import Admin from flask_apscheduler import APScheduler from flask_assets import Environment from flask_login import LoginManager @@ -15,10 +16,12 @@ from flask_sqlalchemy import SQLAlchemy from flask_hashids import Hashids from logging import Formatter, StreamHandler from werkzeug.middleware.proxy_fix import ProxyFix +from .extensions.nopaque_flask_admin_views import AdminIndexView, ModelView docker_client = DockerClient.from_env() +admin = Admin() apifairy = APIFairy() assets = Environment() db = SQLAlchemy() @@ -74,6 +77,7 @@ def create_app(config: Config = Config) -> Flask: from .models import AnonymousUser, User + admin.init_app(app, index_view=AdminIndexView()) apifairy.init_app(app) assets.init_app(app) db.init_app(app) @@ -92,9 +96,6 @@ def create_app(config: Config = Config) -> Flask: # endregion Extensions # region Blueprints - from .blueprints.admin import bp as admin_blueprint - app.register_blueprint(admin_blueprint, url_prefix='/admin') - from .blueprints.api import bp as api_blueprint app.register_blueprint(api_blueprint, url_prefix='/api') @@ -127,6 +128,10 @@ def create_app(config: Config = Config) -> Flask: from .blueprints.workshops import bp as workshops_blueprint app.register_blueprint(workshops_blueprint, url_prefix='/workshops') + + from .models import _models + for model in _models: + admin.add_view(ModelView(model, db.session, category='Database')) # endregion Blueprints # region SocketIO Namespaces diff --git a/app/blueprints/admin/__init__.py b/app/blueprints/admin/__init__.py deleted file mode 100644 index be000a9a..00000000 --- a/app/blueprints/admin/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -from flask import Blueprint -from flask_login import login_required -from app.decorators import admin_required - - -bp = Blueprint('admin', __name__) - - -@bp.before_request -@login_required -@admin_required -def before_request(): - ''' - Ensures that the routes in this package can be visited only by users with - administrator privileges (login_required and admin_required). - ''' - pass - - -from . import json_routes, routes diff --git a/app/blueprints/admin/forms.py b/app/blueprints/admin/forms.py deleted file mode 100644 index ea684624..00000000 --- a/app/blueprints/admin/forms.py +++ /dev/null @@ -1,16 +0,0 @@ -from flask_wtf import FlaskForm -from wtforms import SelectField, SubmitField -from app.models import Role - - -class UpdateUserForm(FlaskForm): - role = SelectField('Role') - submit = SubmitField() - - def __init__(self, user, *args, **kwargs): - if 'data' not in kwargs: - kwargs['data'] = {'role': user.role.hashid} - if 'prefix' not in kwargs: - kwargs['prefix'] = 'update-user-form' - super().__init__(*args, **kwargs) - self.role.choices = [(x.hashid, x.name) for x in Role.query.all()] diff --git a/app/blueprints/admin/json_routes.py b/app/blueprints/admin/json_routes.py deleted file mode 100644 index e120a654..00000000 --- a/app/blueprints/admin/json_routes.py +++ /dev/null @@ -1,23 +0,0 @@ -from flask import abort, request -from app.decorators import content_negotiation -from app import db -from app.models import User -from . import bp - - -@bp.route('/users//confirmed', methods=['PUT']) -@content_negotiation(consumes='application/json', produces='application/json') -def update_user_role(user_id): - confirmed = request.json - if not isinstance(confirmed, bool): - abort(400) - user = User.query.get_or_404(user_id) - user.confirmed = confirmed - db.session.commit() - response_data = { - 'message': ( - f'User "{user.username}" is now ' - f'{"confirmed" if confirmed else "unconfirmed"}' - ) - } - return response_data, 200 diff --git a/app/blueprints/admin/routes.py b/app/blueprints/admin/routes.py deleted file mode 100644 index f13a8d7c..00000000 --- a/app/blueprints/admin/routes.py +++ /dev/null @@ -1,136 +0,0 @@ -from flask import abort, flash, redirect, render_template, url_for -from app import db, hashids -from app.models import Avatar, Corpus, Role, User -from app.blueprints.users.settings.forms import ( - UpdateAvatarForm, - UpdatePasswordForm, - UpdateNotificationsForm, - UpdateAccountInformationForm, - UpdateProfileInformationForm -) -from . import bp -from .forms import UpdateUserForm - - -@bp.route('') -def admin(): - return render_template( - 'admin/admin.html.j2', - title='Administration' - ) - - -@bp.route('/corpora') -def corpora(): - corpora = Corpus.query.all() - return render_template( - 'admin/corpora.html.j2', - title='Corpora', - corpora=corpora - ) - - -@bp.route('/users') -def users(): - users = User.query.all() - return render_template( - 'admin/users.html.j2', - title='Users', - users=users - ) - - -@bp.route('/users/') -def user(user_id): - user = User.query.get_or_404(user_id) - corpora = Corpus.query.filter(Corpus.user == user).all() - return render_template( - 'admin/user.html.j2', - title=user.username, - user=user, - corpora=corpora - ) - - -@bp.route('/users//settings', methods=['GET', 'POST']) -def user_settings(user_id): - user = User.query.get_or_404(user_id) - update_account_information_form = UpdateAccountInformationForm(user) - update_profile_information_form = UpdateProfileInformationForm(user) - update_avatar_form = UpdateAvatarForm() - update_password_form = UpdatePasswordForm(user) - update_notifications_form = UpdateNotificationsForm(user) - update_user_form = UpdateUserForm(user) - - # region handle update profile information form - if update_profile_information_form.submit.data and update_profile_information_form.validate(): - user.about_me = update_profile_information_form.about_me.data - user.location = update_profile_information_form.location.data - user.organization = update_profile_information_form.organization.data - user.website = update_profile_information_form.website.data - user.full_name = update_profile_information_form.full_name.data - db.session.commit() - flash('Your changes have been saved') - return redirect(url_for('.user_settings', user_id=user.id)) - # endregion handle update profile information form - - # region handle update avatar form - if update_avatar_form.submit.data and update_avatar_form.validate(): - try: - Avatar.create( - update_avatar_form.avatar.data, - user=user - ) - except (AttributeError, OSError): - abort(500) - db.session.commit() - flash('Your changes have been saved') - return redirect(url_for('.user_settings', user_id=user.id)) - # endregion handle update avatar form - - # region handle update account information form - if update_account_information_form.submit.data and update_account_information_form.validate(): - user.email = update_account_information_form.email.data - user.username = update_account_information_form.username.data - db.session.commit() - flash('Profile settings updated') - return redirect(url_for('.user_settings', user_id=user.id)) - # endregion handle update account information form - - # region handle update password form - if update_password_form.submit.data and update_password_form.validate(): - user.password = update_password_form.new_password.data - db.session.commit() - flash('Your changes have been saved') - return redirect(url_for('.user_settings', user_id=user.id)) - # endregion handle update password form - - # region handle update notifications form - if update_notifications_form.submit.data and update_notifications_form.validate(): - user.setting_job_status_mail_notification_level = \ - update_notifications_form.job_status_mail_notification_level.data - db.session.commit() - flash('Your changes have been saved') - return redirect(url_for('.user_settings', user_id=user.id)) - # endregion handle update notifications form - - # region handle update user form - if update_user_form.submit.data and update_user_form.validate(): - role_id = hashids.decode(update_user_form.role.data) - user.role = Role.query.get(role_id) - db.session.commit() - flash('Your changes have been saved') - return redirect(url_for('.user_settings', user_id=user.id)) - # endregion handle update user form - - return render_template( - 'admin/user_settings.html.j2', - title='Settings', - update_account_information_form=update_account_information_form, - update_avatar_form=update_avatar_form, - update_notifications_form=update_notifications_form, - update_password_form=update_password_form, - update_profile_information_form=update_profile_information_form, - update_user_form=update_user_form, - user=user - ) diff --git a/app/extensions/nopaque_flask_admin_views.py b/app/extensions/nopaque_flask_admin_views.py new file mode 100644 index 00000000..ebc7e45c --- /dev/null +++ b/app/extensions/nopaque_flask_admin_views.py @@ -0,0 +1,20 @@ +from flask import abort +from flask_admin import ( + AdminIndexView as _AdminIndexView, + expose +) +from flask_admin.contrib.sqla import ModelView as _ModelView +from flask_login import current_user + + +class AdminIndexView(_AdminIndexView): + @expose('/') + def index(self): + if not current_user.is_administrator: + abort(403) + return super().index() + + +class ModelView(_ModelView): + def is_accessible(self): + return current_user.is_administrator diff --git a/app/extensions/nopaque_sqlalchemy_extras/__init__.py b/app/extensions/nopaque_sqlalchemy_extras/__init__.py deleted file mode 100644 index 47f029db..00000000 --- a/app/extensions/nopaque_sqlalchemy_extras/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .types import ContainerColumn -from .types import IntEnumColumn diff --git a/app/extensions/nopaque_sqlalchemy_extras/types.py b/app/extensions/nopaque_sqlalchemy_type_decorators.py similarity index 100% rename from app/extensions/nopaque_sqlalchemy_extras/types.py rename to app/extensions/nopaque_sqlalchemy_type_decorators.py diff --git a/app/models/__init__.py b/app/models/__init__.py index 11a0f680..ec9ca6e5 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,14 +1,45 @@ -from .anonymous_user import * -from .avatar import * -from .corpus_file import * -from .corpus_follower_association import * -from .corpus_follower_role import * -from .corpus import * -from .job_input import * -from .job_result import * -from .job import * -from .role import * -from .spacy_nlp_pipeline_model import * -from .tesseract_ocr_pipeline_model import * -from .token import * -from .user import * +from .anonymous_user import AnonymousUser +from .avatar import Avatar +from .corpus_file import CorpusFile +from .corpus_follower_association import CorpusFollowerAssociation +from .corpus_follower_role import CorpusFollowerPermission, CorpusFollowerRole +from .corpus import CorpusStatus, Corpus +from .job_input import JobInput +from .job_result import JobResult +from .job import JobStatus, Job +from .role import Permission, Role +from .spacy_nlp_pipeline_model import SpaCyNLPPipelineModel +from .tesseract_ocr_pipeline_model import TesseractOCRPipelineModel +from .token import Token +from .user import ( + ProfilePrivacySettings, + UserSettingJobStatusMailNotificationLevel, + User +) + + +_models = [ + Avatar, + CorpusFile, + CorpusFollowerAssociation, + CorpusFollowerRole, + Corpus, + JobInput, + JobResult, + Job, + Role, + SpaCyNLPPipelineModel, + TesseractOCRPipelineModel, + Token, + User +] + + +_enums = [ + CorpusFollowerPermission, + CorpusStatus, + JobStatus, + Permission, + ProfilePrivacySettings, + UserSettingJobStatusMailNotificationLevel +] diff --git a/app/models/corpus.py b/app/models/corpus.py index 54894c67..2d41ed0f 100644 --- a/app/models/corpus.py +++ b/app/models/corpus.py @@ -8,7 +8,7 @@ import shutil import xml.etree.ElementTree as ET from app import db from app.converters.vrt import normalize_vrt_file -from app.extensions.nopaque_sqlalchemy_extras import IntEnumColumn +from app.extensions.nopaque_sqlalchemy_type_decorators import IntEnumColumn from .corpus_follower_association import CorpusFollowerAssociation diff --git a/app/models/job.py b/app/models/job.py index e35cdc83..308cf925 100644 --- a/app/models/job.py +++ b/app/models/job.py @@ -6,7 +6,7 @@ from time import sleep from pathlib import Path import shutil from app import db -from app.extensions.nopaque_sqlalchemy_extras import ContainerColumn, IntEnumColumn +from app.extensions.nopaque_sqlalchemy_type_decorators import ContainerColumn, IntEnumColumn class JobStatus(IntEnum): diff --git a/app/models/spacy_nlp_pipeline_model.py b/app/models/spacy_nlp_pipeline_model.py index 4aa10ce9..0c7c2c07 100644 --- a/app/models/spacy_nlp_pipeline_model.py +++ b/app/models/spacy_nlp_pipeline_model.py @@ -5,7 +5,7 @@ from pathlib import Path import requests import yaml from app import db -from app.extensions.nopaque_sqlalchemy_extras import ContainerColumn +from app.extensions.nopaque_sqlalchemy_type_decorators import ContainerColumn from .file_mixin import FileMixin from .user import User diff --git a/app/models/tesseract_ocr_pipeline_model.py b/app/models/tesseract_ocr_pipeline_model.py index e1512ca7..f295b697 100644 --- a/app/models/tesseract_ocr_pipeline_model.py +++ b/app/models/tesseract_ocr_pipeline_model.py @@ -5,7 +5,7 @@ from pathlib import Path import requests import yaml from app import db -from app.extensions.nopaque_sqlalchemy_extras import ContainerColumn +from app.extensions.nopaque_sqlalchemy_type_decorators import ContainerColumn from .file_mixin import FileMixin from .user import User diff --git a/app/models/user.py b/app/models/user.py index 77031983..c454f6b6 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -11,7 +11,7 @@ import re import secrets import shutil from app import db, hashids -from app.extensions.nopaque_sqlalchemy_extras import IntEnumColumn +from app.extensions.nopaque_sqlalchemy_type_decorators import IntEnumColumn from .corpus import Corpus from .corpus_follower_association import CorpusFollowerAssociation from .corpus_follower_role import CorpusFollowerRole diff --git a/app/templates/_base/dropdowns.html.j2 b/app/templates/_base/dropdowns.html.j2 index 3c1b2772..b12e6ba8 100644 --- a/app/templates/_base/dropdowns.html.j2 +++ b/app/templates/_base/dropdowns.html.j2 @@ -68,8 +68,8 @@ {% endif %} {% if current_user.is_administrator %} -
  • - +
  • + admin_panel_settings Administration diff --git a/app/templates/_base/sidenav.html.j2 b/app/templates/_base/sidenav.html.j2 index dff0b45c..67c76659 100644 --- a/app/templates/_base/sidenav.html.j2 +++ b/app/templates/_base/sidenav.html.j2 @@ -118,8 +118,8 @@ {% if current_user.is_administrator %} {# Administration #} -
  • - admin_panel_settingsAdministration +
  • + admin_panel_settingsAdministration
  • {% endif %} diff --git a/app/templates/admin/admin.html.j2 b/app/templates/admin/admin.html.j2 deleted file mode 100644 index f944bbe9..00000000 --- a/app/templates/admin/admin.html.j2 +++ /dev/null @@ -1,43 +0,0 @@ -{% extends "base.html.j2" %} - -{% block page_content %} -
    -
    -
    -

    {{ title }}

    -
    - -
    -
    - -
    - groupUsers -

    Edit the individual user accounts. You have the following options:

    -
      -
    • - View, edit and delete user accounts
    • -
    • - View, edit and delete user corpora
    • -
    • - View, edit and delete user jobs
    • -
    • - View, edit and delete user added Tesseract models
    • -
    • - View, edit and delete user added SpaCy models
    • -
    -
    -
    -
    - -
    -
    - -
    - ICorpora -

    Edit all Corpora. You have the following options:

    -
      -
    • - View, edit and delete corpora
    • -
    • - View, edit and delete corpus jobs
    • -
    • - Edit corpus follower roles and the public status of the corpus
    • -
    -
    -
    -
    -
    -
    -{% endblock page_content %} diff --git a/app/templates/admin/corpora.html.j2 b/app/templates/admin/corpora.html.j2 deleted file mode 100644 index 295013f2..00000000 --- a/app/templates/admin/corpora.html.j2 +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "base.html.j2" %} - -{% block page_content %} -
    -

    {{ title }}

    - -
    -
    -
    -
    -
    -
    - -{% endblock page_content %} - -{% block scripts %} -{{ super() }} - -{% endblock scripts %} diff --git a/app/templates/admin/edit_user.html.j2 b/app/templates/admin/edit_user.html.j2 deleted file mode 100644 index 30b0b961..00000000 --- a/app/templates/admin/edit_user.html.j2 +++ /dev/null @@ -1,104 +0,0 @@ -{% extends "base.html.j2" %} -{% import "wtf.html.j2" as wtf %} - -{% block page_content %} -
    -
    -
    -

    {{ title }}

    -
    - -
    -
    - {{ edit_profile_settings_form.hidden_tag() }} -
    -
    - General settings - {{ wtf.render_field(edit_profile_settings_form.username, material_icon='person') }} - {{ wtf.render_field(edit_profile_settings_form.email, material_icon='email') }} -
    -
    -
    - {{ wtf.render_field(edit_profile_settings_form.submit, material_icon='send') }} -
    -
    - -
    - - -
    - {{ edit_notification_settings_form.hidden_tag() }} -
    -
    - Notification settings - {{ wtf.render_field(edit_notification_settings_form.job_status_mail_notification_level, material_icon='notifications') }} -
    -
    -
    - {{ wtf.render_field(edit_notification_settings_form.submit, material_icon='send') }} -
    -
    -
    -
    - -
    - {{ admin_edit_user_form.hidden_tag() }} -
    -
    - Administrator settings - {{ wtf.render_field(admin_edit_user_form.role, material_icon='swap_vert') }} -
    -

     

    -
    -

    check

    -
    -
    -

    {{ admin_edit_user_form.confirmed.label.text }}

    -

    Change confirmation status manually.

    -
    -
    -
    - -
    -
    -
    -
    -
    - {{ wtf.render_field(admin_edit_user_form.submit, material_icon='send') }} -
    -
    -
    - -
    -
    - Delete account -

    Deleting an account has the following effects:

    -
      -
    • All data associated with your corpora and jobs will be permanently deleted.
    • -
    • All settings will be permanently deleted.
    • -
    -
    - -
    -
    -
    -{% endblock page_content %} - -{% block modals %} -{{ super() }} - -{% endblock modals %} diff --git a/app/templates/admin/user.html.j2 b/app/templates/admin/user.html.j2 deleted file mode 100644 index 4d99ea57..00000000 --- a/app/templates/admin/user.html.j2 +++ /dev/null @@ -1,131 +0,0 @@ -{% extends "base.html.j2" %} - -{% block page_content %} -
    -
    -
    -

     

    - {# user-image #} -
    -
    -

    {{ title }}

    -

    - {{ user.role.name }} - {% if user.confirmed %} - confirmed - {% else %} - unconfirmed - {% endif %} -

    - {% if user.about_me %} -

    {{ user.about_me }}

    - {% endif %} -
    - -
     
    - - - -
    -
    -
    -
      -
    • Username: {{ user.username }}
    • -
    • Email: {{ user.email }}
    • -
    • Id: {{ user.id }}
    • -
    • Hashid: {{ user.hashid }}
    • -
    • Member since: {{ user.member_since.strftime('%Y-%m-%d') }}
    • -
    • Last seen: {% if user.last_seen %}{{ user.last_seen.strftime('%Y-%m-%d') }}
    • {% endif %} -
    -
    - -
    -
    - -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -{% endblock page_content %} - - -{% block modals %} -{{ super() }} - -{% endblock modals %} - - -{% block scripts %} -{{ super() }} - -{% endblock scripts %} diff --git a/app/templates/admin/user_settings.html.j2 b/app/templates/admin/user_settings.html.j2 deleted file mode 100644 index 17fdc151..00000000 --- a/app/templates/admin/user_settings.html.j2 +++ /dev/null @@ -1,66 +0,0 @@ -{% extends "users/settings/settings.html.j2" %} - -{% block admin_settings %} -
    - -
    -

    Administrator Settings

    -

    Here the Confirmation Status of the user can be set manually and a special role can be assigned.

    -
    -
    -
    -
      -
    • -
      - Confirmation status - keyboard_arrow_right -
      -
      -
      -

      check

      -

      - Confirmed
      - Change confirmation status manually. -

      -
      -
      - -
      -
      -
      -
    • -
    • -
      - Role - keyboard_arrow_right -
      -
      -
      - {{ update_user_form.hidden_tag() }} - {{ wtf.render_field(update_user_form.role, material_icon='manage_accounts') }} -
      - {{ wtf.render_field(update_user_form.submit, material_icon='send') }} -
      -
      -
      -
    • -
    -
    -{% endblock admin_settings %} - -{% block scripts %} -{{ super() }} - -{% endblock scripts %} diff --git a/app/templates/admin/users.html.j2 b/app/templates/admin/users.html.j2 deleted file mode 100644 index eccdd4f4..00000000 --- a/app/templates/admin/users.html.j2 +++ /dev/null @@ -1,34 +0,0 @@ -{% extends "base.html.j2" %} - -{% block page_content %} -
    -
    -
    -

    {{ title }}

    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -{% endblock page_content %} - -{% block scripts %} -{{ super() }} - -{% endblock scripts %} diff --git a/app/templates/settings/index.html.j2 b/app/templates/settings/index.html.j2 index a27e7829..7e81e259 100644 --- a/app/templates/settings/index.html.j2 +++ b/app/templates/settings/index.html.j2 @@ -172,8 +172,6 @@
    - - {% block admin_settings %}{% endblock admin_settings %} {% endblock page_content %} diff --git a/requirements.freezed.txt b/requirements.freezed.txt index a5c2daa4..b1af90d7 100644 --- a/requirements.freezed.txt +++ b/requirements.freezed.txt @@ -14,6 +14,7 @@ docker==7.0.0 email_validator==2.1.1 eventlet==0.34.2 Flask==2.3.3 +Flask-Admin==1.6.1 Flask-APScheduler==1.13.1 Flask-Assets==2.1.0 Flask-Hashids==1.0.3 diff --git a/requirements.txt b/requirements.txt index 71c7daaa..74939ff7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ dnspython==2.5.0 docker eventlet==0.34.2 Flask==2.3.3 +Flask-Admin==1.6.1 Flask-APScheduler Flask-Assets Flask-Hashids