diff --git a/app/corpora/forms.py b/app/corpora/forms.py index 8950622d..8403e621 100644 --- a/app/corpora/forms.py +++ b/app/corpora/forms.py @@ -78,9 +78,6 @@ class UpdateCorpusFileForm(CorpusFileBaseForm): kwargs['prefix'] = 'update-corpus-file-form' super().__init__(*args, **kwargs) -class ChangeCorpusSettingsForm(FlaskForm): - is_public = BooleanField('Public Corpus') - submit = SubmitField() class ImportCorpusForm(FlaskForm): pass diff --git a/app/corpora/routes.py b/app/corpora/routes.py index e1c67f40..4b10fa2c 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -1,3 +1,4 @@ +from datetime import datetime from flask import ( abort, current_app, @@ -11,21 +12,124 @@ from flask import ( ) from flask_login import current_user, login_required from threading import Thread +import jwt import os from app import db, hashids -from app.models import Corpus, CorpusFile, CorpusStatus, CorpusFollowerAssociation, User +from app.models import Corpus, CorpusFile, CorpusStatus, User from . import bp -from .forms import ChangeCorpusSettingsForm, CreateCorpusFileForm, CreateCorpusForm, UpdateCorpusFileForm +from .forms import ( + CreateCorpusFileForm, + CreateCorpusForm, + UpdateCorpusFileForm +) -@bp.route('') +# @bp.route('/share/', methods=['GET', 'POST']) +# def share_corpus(token): +# try: +# payload = jwt.decode( +# token, +# current_app.config['SECRET_KEY'], +# algorithms=['HS256'], +# issuer=current_app.config['SERVER_NAME'], +# options={'require': ['iat', 'iss', 'sub']} +# ) +# except jwt.PyJWTError: +# return False +# corpus_hashid = payload.get('sub') +# corpus_id = hashids.decode(corpus_hashid) +# return redirect(url_for('.corpus', corpus_id=corpus_id)) + + +@bp.route('//enable_is_public', methods=['POST']) @login_required -def corpora(): - query = Corpus.query.filter( - (Corpus.user_id == current_user.id) | (Corpus.is_public == True) +def enable_corpus_is_public(corpus_id): + corpus = Corpus.query.get_or_404(corpus_id) + if not (corpus.user == current_user or current_user.is_administrator()): + abort(403) + corpus.is_public = True + db.session.commit() + return '', 204 + + +@bp.route('//disable_is_public', methods=['POST']) +@login_required +def disable_corpus_is_public(corpus_id): + corpus = Corpus.query.get_or_404(corpus_id) + if not (corpus.user == current_user or current_user.is_administrator()): + abort(403) + corpus.is_public = False + db.session.commit() + return '', 204 + + +# @bp.route('//follow', methods=['GET', 'POST']) +# @login_required +# def follow_corpus(corpus_id): +# corpus = Corpus.query.get_or_404(corpus_id) +# user_hashid = request.args.get('user_id') +# if user_hashid is None: +# user = current_user +# else: +# if not current_user.is_administrator(): +# abort(403) +# else: +# user_id = hashids.decode(user_hashid) +# user = User.query.get_or_404(user_id) +# if not user.is_following_corpus(corpus): +# user.follow_corpus(corpus) +# db.session.commit() +# flash(f'You are following {corpus.title} now', category='corpus') +# return {}, 202 + + +@bp.route('//unfollow', methods=['GET', 'POST']) +@login_required +def unfollow_corpus(corpus_id): + corpus = Corpus.query.get_or_404(corpus_id) + user_hashid = request.args.get('user_id') + if user_hashid is None: + user = current_user + elif current_user.is_administrator(): + user_id = hashids.decode(user_hashid) + user = User.query.get_or_404(user_id) + else: + abort(403) + if user.is_following_corpus(corpus): + user.unfollow_corpus(corpus) + db.session.commit() + flash(f'You are not following {corpus.title} anymore', category='corpus') + return {}, 202 + + +# @bp.route('/add_permission///') +# def add_permission(corpus_id, user_id, permission): +# a = CorpusFollowerAssociation.query.filter_by(followed_corpus_id=corpus_id, following_user_id=user_id).first_or_404() +# a.add_permission(permission) +# db.session.commit() +# return 'ok' + + +# @bp.route('/remove_permission///') +# def remove_permission(corpus_id, user_id, permission): +# a = CorpusFollowerAssociation.query.filter_by(followed_corpus_id=corpus_id, following_user_id=user_id).first_or_404() +# a.remove_permission(permission) +# db.session.commit() +# return 'ok' + + +@bp.route('/public') +@login_required +def public_corpora(): + corpora = [ + c.to_json_serializeable() + for c in Corpus.query.filter(Corpus.is_public == True).all() + ] + return render_template( + 'corpora/public_corpora.html.j2', + corpora=corpora, + title='Corpora' ) - corpora = [c.to_json_serializeable() for c in query.all()] - return render_template('corpora/corpora.html.j2', corpora=corpora, title='Corpora') @bp.route('/create', methods=['GET', 'POST']) @@ -58,48 +162,34 @@ def create_corpus(): @login_required def corpus(corpus_id): corpus = Corpus.query.get_or_404(corpus_id) - if not (corpus.user == current_user - or current_user.is_administrator() - or current_user.is_following_corpus(corpus) - or corpus.is_public): - abort(403) - corpus_settings_form = ChangeCorpusSettingsForm( - data=corpus.to_json_serializeable(), - prefix='corpus-settings-form' - ) - if corpus_settings_form.validate_on_submit(): - corpus.is_public = corpus_settings_form.is_public.data - db.session.commit() - flash('Your changes have been saved') - return redirect(url_for('.corpus', corpus_id=corpus.id)) if corpus.user == current_user or current_user.is_administrator(): + # now = datetime.utcnow() + # payload = { + # 'exp': now + timedelta(weeks=1), + # 'iat': now, + # 'iss': current_app.config['SERVER_NAME'], + # 'sub': corpus.hashid + # } + # token = jwt.encode( + # payload, + # current_app.config['SECRET_KEY'], + # algorithm='HS256' + # ) return render_template( 'corpora/corpus.html.j2', - corpus_settings_form=corpus_settings_form, corpus=corpus, + # token=token, title='Corpus' ) - else: - print('public') + if current_user.is_following_corpus(corpus) or corpus.is_public: + corpus_files = [x.to_json_serializeable() for x in corpus.files] return render_template( - 'corpora/corpus_public.html.j2', + 'corpora/public_corpus.html.j2', corpus=corpus, + corpus_files=corpus_files, title='Corpus' ) - - - -# @bp.route('//update') -# @login_required -# def update_corpus(corpus_id): -# corpus = Corpus.query.get_or_404(corpus_id) -# if not (corpus.user == current_user or current_user.is_administrator()): -# abort(403) -# return render_template( -# 'corpora/update_corpus.html.j2', -# corpus=corpus, -# title='Corpus' -# ) + abort(403) @bp.route('/', methods=['DELETE']) @@ -128,8 +218,7 @@ def analyse_corpus(corpus_id): corpus = Corpus.query.get_or_404(corpus_id) if not (corpus.user == current_user or current_user.is_administrator() - or current_user.is_following_corpus(corpus) - or corpus.is_public): + or current_user.is_following_corpus(corpus)): abort(403) return render_template( 'corpora/analyse_corpus.html.j2', @@ -278,55 +367,3 @@ def import_corpus(): @login_required def export_corpus(corpus_id): abort(503) - -@bp.route('//follow', methods=['GET', 'POST']) -@login_required -def follow_corpus(corpus_id): - corpus = Corpus.query.get_or_404(corpus_id) - user_hashid = request.args.get('user_id') - if user_hashid is None: - user = current_user - else: - if not current_user.is_administrator(): - abort(403) - else: - user_id = hashids.decode(user_hashid) - user = User.query.get_or_404(user_id) - if not user.is_following_corpus(corpus): - user.follow_corpus(corpus) - db.session.commit() - return {}, 202 - -@bp.route('//unfollow', methods=['GET', 'POST']) -@login_required -def unfollow_corpus(corpus_id): - corpus = Corpus.query.get_or_404(corpus_id) - user_hashid = request.args.get('user_id') - if user_hashid is None: - user = current_user - else: - if not current_user.is_administrator(): - abort(403) - else: - user_id = hashids.decode(user_hashid) - user = User.query.get_or_404(user_id) - if user.is_following_corpus(corpus): - user.unfollow_corpus(corpus) - db.session.commit() - return {}, 202 - -@bp.route('/add_permission///') -def add_permission(corpus_id, user_id, permission): - a = CorpusFollowerAssociation.query.filter_by(followed_corpus_id=corpus_id, following_user_id=user_id).first_or_404() - a.add_permission(permission) - db.session.commit() - return 'ok' - - -@bp.route('/remove_permission///') -def remove_permission(corpus_id, user_id, permission): - a = CorpusFollowerAssociation.query.filter_by(followed_corpus_id=corpus_id, following_user_id=user_id).first_or_404() - a.remove_permission(permission) - db.session.commit() - return 'ok' - diff --git a/app/main/routes.py b/app/main/routes.py index aed63853..eff0dadd 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -27,15 +27,20 @@ def faq(): @bp.route('/dashboard') @login_required def dashboard(): - users = [ - u.to_json_serializeable(filter_by_privacy_settings=True) for u - in User.query.filter(User.is_public == True, User.id != current_user.id).all() - ] - corpora = [ - c.to_json_serializeable() for c - in Corpus.query.filter(Corpus.is_public == True).all() - ] - return render_template('main/dashboard.html.j2', title='Dashboard', users=users, corpora=corpora) + # users = [ + # u.to_json_serializeable(filter_by_privacy_settings=True) for u + # in User.query.filter(User.is_public == True, User.id != current_user.id).all() + # ] + # corpora = [ + # c.to_json_serializeable() for c + # in Corpus.query.filter(Corpus.is_public == True, Corpus.user != current_user).all() + # ] + return render_template( + 'main/dashboard.html.j2', + title='Dashboard', + # users=users, + # corpora=corpora + ) @bp.route('/dashboard2') diff --git a/app/static/js/ResourceLists/CorpusFileList.js b/app/static/js/ResourceLists/CorpusFileList.js index ca27fe27..c052b2ea 100644 --- a/app/static/js/ResourceLists/CorpusFileList.js +++ b/app/static/js/ResourceLists/CorpusFileList.js @@ -11,6 +11,7 @@ class CorpusFileList extends ResourceList { this.isInitialized = false; this.userId = listContainerElement.dataset.userId; this.corpusId = listContainerElement.dataset.corpusId; + if (this.userId === undefined || this.corpusId === undefined) {return;} app.subscribeUser(this.userId).then((response) => { app.socket.on('PATCH', (patch) => { if (this.isInitialized) {this.onPatch(patch);} diff --git a/app/static/js/ResourceLists/CorpusList.js b/app/static/js/ResourceLists/CorpusList.js index e4af0b12..bf62ebed 100644 --- a/app/static/js/ResourceLists/CorpusList.js +++ b/app/static/js/ResourceLists/CorpusList.js @@ -8,8 +8,9 @@ class CorpusList extends ResourceList { constructor(listContainerElement, options = {}) { super(listContainerElement, options); this.listjs.list.addEventListener('click', (event) => {this.onClick(event)}); - this.isInitialized = false; + this.isInitialized = false this.userId = listContainerElement.dataset.userId; + if (this.userId === undefined) {return;} app.subscribeUser(this.userId).then((response) => { app.socket.on('PATCH', (patch) => { if (this.isInitialized) {this.onPatch(patch);} diff --git a/app/static/js/ResourceLists/JobInputList.js b/app/static/js/ResourceLists/JobInputList.js index 36cb47f7..7f1a5105 100644 --- a/app/static/js/ResourceLists/JobInputList.js +++ b/app/static/js/ResourceLists/JobInputList.js @@ -11,6 +11,7 @@ class JobInputList extends ResourceList { this.isInitialized = false; this.userId = listContainerElement.dataset.userId; this.jobId = listContainerElement.dataset.jobId; + if (this.userId === undefined || this.jobId === undefined) {return;} app.subscribeUser(this.userId).then((response) => { app.socket.on('PATCH', (patch) => { if (this.isInitialized) {this.onPatch(patch);} diff --git a/app/static/js/ResourceLists/JobList.js b/app/static/js/ResourceLists/JobList.js index 1fa14ea2..ff7f82b2 100644 --- a/app/static/js/ResourceLists/JobList.js +++ b/app/static/js/ResourceLists/JobList.js @@ -10,6 +10,7 @@ class JobList extends ResourceList { this.listjs.list.addEventListener('click', (event) => {this.onClick(event)}); this.isInitialized = false; this.userId = listContainerElement.dataset.userId; + if (this.userId === undefined) {return;} app.subscribeUser(this.userId).then((response) => { app.socket.on('PATCH', (patch) => { if (this.isInitialized) {this.onPatch(patch);} diff --git a/app/static/js/ResourceLists/JobResultList.js b/app/static/js/ResourceLists/JobResultList.js index 71c430af..b0cbc088 100644 --- a/app/static/js/ResourceLists/JobResultList.js +++ b/app/static/js/ResourceLists/JobResultList.js @@ -11,6 +11,7 @@ class JobResultList extends ResourceList { this.isInitialized = false; this.userId = listContainerElement.dataset.userId; this.jobId = listContainerElement.dataset.jobId; + if (this.userId === undefined || this.jobId === undefined) {return;} app.subscribeUser(this.userId).then((response) => { app.socket.on('PATCH', (patch) => { if (this.isInitialized) {this.onPatch(patch);} diff --git a/app/static/js/ResourceLists/PublicCorpusFileList.js b/app/static/js/ResourceLists/PublicCorpusFileList.js new file mode 100644 index 00000000..37a284cf --- /dev/null +++ b/app/static/js/ResourceLists/PublicCorpusFileList.js @@ -0,0 +1,15 @@ +class PublicCorpusFileList extends CorpusFileList { + get item() { + return ` + + + + + + + send + + + `.trim(); + } +} diff --git a/app/static/js/ResourceLists/PublicCorpusList.js b/app/static/js/ResourceLists/PublicCorpusList.js new file mode 100644 index 00000000..fdb2e9ed --- /dev/null +++ b/app/static/js/ResourceLists/PublicCorpusList.js @@ -0,0 +1,14 @@ +class PublicCorpusList extends CorpusList { + get item() { + return ` + + book +
+ + + send + + + `.trim(); + } +} diff --git a/app/static/js/ResourceLists/SpacyNLPPipelineModelList.js b/app/static/js/ResourceLists/SpacyNLPPipelineModelList.js index 078ec90c..b90fb06b 100644 --- a/app/static/js/ResourceLists/SpacyNLPPipelineModelList.js +++ b/app/static/js/ResourceLists/SpacyNLPPipelineModelList.js @@ -11,6 +11,7 @@ class SpaCyNLPPipelineModelList extends ResourceList { this.listjs.list.addEventListener('click', (event) => {this.onClick(event)}); this.isInitialized = false; this.userId = listContainerElement.dataset.userId; + if (this.userId === undefined) {return;} app.subscribeUser(this.userId).then((response) => { app.socket.on('PATCH', (patch) => { if (this.isInitialized) {this.onPatch(patch);} diff --git a/app/static/js/ResourceLists/TesseractOCRPipelineModelList.js b/app/static/js/ResourceLists/TesseractOCRPipelineModelList.js index ad319041..4ad3f5b1 100644 --- a/app/static/js/ResourceLists/TesseractOCRPipelineModelList.js +++ b/app/static/js/ResourceLists/TesseractOCRPipelineModelList.js @@ -11,6 +11,7 @@ class TesseractOCRPipelineModelList extends ResourceList { this.listjs.list.addEventListener('click', (event) => {this.onClick(event)}); this.isInitialized = false; this.userId = listContainerElement.dataset.userId; + if (this.userId === undefined) {return;} app.subscribeUser(this.userId).then((response) => { app.socket.on('PATCH', (patch) => { if (this.isInitialized) {this.onPatch(patch);} diff --git a/app/static/js/RessourceDisplays/CorpusDisplay.js b/app/static/js/RessourceDisplays/CorpusDisplay.js index af4da56a..fd342dd4 100644 --- a/app/static/js/RessourceDisplays/CorpusDisplay.js +++ b/app/static/js/RessourceDisplays/CorpusDisplay.js @@ -12,6 +12,16 @@ class CorpusDisplay extends RessourceDisplay { .addEventListener('click', (event) => { Utils.deleteCorpusRequest(this.userId, this.corpusId); }); + this.displayElement + .querySelector('.action-switch[data-action="toggle-is-public"]') + .addEventListener('click', (event) => { + if (event.target.tagName !== 'INPUT') {return;} + if (event.target.checked) { + Utils.enableCorpusIsPublicRequest(this.userId, this.corpusId); + } else { + Utils.disableCorpusIsPublicRequest(this.userId, this.corpusId); + } + }); } init(user) { diff --git a/app/static/js/Utils.js b/app/static/js/Utils.js index d63be025..aee382a0 100644 --- a/app/static/js/Utils.js +++ b/app/static/js/Utils.js @@ -69,6 +69,88 @@ class Utils { return Utils.mergeObjectsDeep(mergedObject, ...objects.slice(2)); } + static enableCorpusIsPublicRequest(userId, corpusId) { + return new Promise((resolve, reject) => { + let corpus; + try { + corpus = app.data.users[userId].corpora[corpusId]; + } catch (error) { + corpus = {}; + } + + let modalElement = Utils.HTMLToElement( + ` + + ` + ); + document.querySelector('#modals').appendChild(modalElement); + let modal = M.Modal.init( + modalElement, + { + dismissible: false, + onCloseEnd: () => { + modal.destroy(); + modalElement.remove(); + } + } + ); + + let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]'); + confirmElement.addEventListener('click', (event) => { + let corpusTitle = corpus?.title; + fetch(`/corpora/${corpusId}/enable_is_public`, {method: 'POST', headers: {Accept: 'application/json'}}) + .then( + (response) => { + if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);} + if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);} + app.flash(`Corpus "${corpusTitle}" is public now`, 'corpus'); + resolve(response); + }, + (response) => { + app.flash('Something went wrong', 'error'); + reject(response); + } + ); + }); + modal.open(); + }); + } + + static disableCorpusIsPublicRequest(userId, corpusId) { + return new Promise((resolve, reject) => { + let corpus; + try { + corpus = app.data.users[userId].corpora[corpusId]; + } catch (error) { + corpus = {}; + } + + let corpusTitle = corpus?.title; + fetch(`/corpora/${corpusId}/disable_is_public`, {method: 'POST', headers: {Accept: 'application/json'}}) + .then( + (response) => { + if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);} + if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);} + app.flash(`Corpus "${corpusTitle}" is private now`, 'corpus'); + resolve(response); + }, + (response) => { + app.flash('Something went wrong', 'error'); + reject(response); + } + ); + }); + } + static buildCorpusRequest(userId, corpusId) { return new Promise((resolve, reject) => { let corpus; diff --git a/app/templates/_scripts.html.j2 b/app/templates/_scripts.html.j2 index 95406079..53a0469c 100644 --- a/app/templates/_scripts.html.j2 +++ b/app/templates/_scripts.html.j2 @@ -20,7 +20,9 @@ 'js/RessourceDisplays/JobDisplay.js', 'js/ResourceLists/ResourceList.js', 'js/ResourceLists/CorpusFileList.js', + 'js/ResourceLists/PublicCorpusFileList.js', 'js/ResourceLists/CorpusList.js', + 'js/ResourceLists/PublicCorpusList.js', 'js/ResourceLists/JobList.js', 'js/ResourceLists/JobInputList.js', 'js/ResourceLists/JobResultList.js', diff --git a/app/templates/corpora/corpus.html.j2 b/app/templates/corpora/corpus.html.j2 index c29da972..34228347 100644 --- a/app/templates/corpora/corpus.html.j2 +++ b/app/templates/corpora/corpus.html.j2 @@ -10,15 +10,7 @@
- {#

#} -

{{ corpus.title }}

- {% if not corpus.user == current_user %} - {% if current_user.is_following_corpus(corpus) %} - addUnfollow Corpus - {% elif not current_user.is_following_corpus(corpus) %} - addFollow Corpus - {% endif %} - {% endif %} +

 

@@ -65,11 +57,13 @@
- @@ -85,25 +79,26 @@ - {% if current_user.can(Permission.ADMINISTRATE) or current_user.hashid == corpus.user.hashid %} -
-
- {{ corpus_settings_form.hidden_tag() }} -
-
- Corpus settings -
-

- {{ wtf.render_field(corpus_settings_form.is_public) }} -
-
-
- {{ wtf.render_field(corpus_settings_form.submit, material_icon='send') }} + + {#
+
+
+
+ +
+ + Generate Share Link + + Copy
- +
- {% endif %} +
@@ -111,7 +106,7 @@
-
+
#}
{% endblock page_content %} @@ -121,4 +116,25 @@ +{# #} {% endblock scripts %} diff --git a/app/templates/corpora/corpus_public.html.j2 b/app/templates/corpora/corpus_public.html.j2 deleted file mode 100644 index 7ca14378..00000000 --- a/app/templates/corpora/corpus_public.html.j2 +++ /dev/null @@ -1,86 +0,0 @@ -{% extends "base.html.j2" %} -{% import "materialize/wtf.html.j2" as wtf %} - -{% block main_attribs %} class="service-scheme" data-service="corpus-analysis"{% endblock main_attribs %} - -{% block page_content %} -
-
-
-

{{ title }}

-
-
-
-
-

Description: {{ corpus.description }}

-
-

-
-
-

Creation date: {{ corpus.creation_date }}

-
-
-

Number of tokens used: {{ corpus.num_tokens }}

-
-
-
-
-
-
-
-{% endblock page_content %} - -{% block scripts %} -{{ super() }} - -{% endblock scripts %} diff --git a/app/templates/corpora/corpora.html.j2 b/app/templates/corpora/public_corpora.html.j2 similarity index 100% rename from app/templates/corpora/corpora.html.j2 rename to app/templates/corpora/public_corpora.html.j2 diff --git a/app/templates/corpora/public_corpus.html.j2 b/app/templates/corpora/public_corpus.html.j2 new file mode 100644 index 00000000..94b3524c --- /dev/null +++ b/app/templates/corpora/public_corpus.html.j2 @@ -0,0 +1,80 @@ +{% extends "base.html.j2" %} +{% import "materialize/wtf.html.j2" as wtf %} + +{% block main_attribs %} class="service-scheme" data-service="corpus-analysis"{% endblock main_attribs %} + +{% block page_content %} +
+
+
+

{{ corpus.title }}

+
+
+ {% if current_user.is_following_corpus(corpus) %} + addUnfollow Corpus + {% endif %} + {% if corpus.status.name in ['BUILT', 'STARTING_ANALYSIS_SESSION', 'RUNNING_ANALYSIS_SESSION', 'CANCELING_ANALYSIS_SESSION'] and current_user.is_following_corpus(corpus) %} + Analyze + {% endif %} +
+
+
+
+
+
+

Status:

+

+
+
+
+

Description: {{ corpus.description }}

+
+

+
+
+

Creation date: {{ corpus.creation_date }}

+
+
+

Number of tokens used: {{ corpus.num_tokens }}

+
+
+
+
+
+
+ Corpus files +
+
+
+
+
+
+ +{% endblock page_content %} + +{% block scripts %} +{{ super() }} + +{% endblock scripts %} diff --git a/app/templates/main/dashboard.html.j2 b/app/templates/main/dashboard.html.j2 index 5391a8af..ca5422df 100644 --- a/app/templates/main/dashboard.html.j2 +++ b/app/templates/main/dashboard.html.j2 @@ -42,7 +42,7 @@ -
+ {#

Social

@@ -58,7 +58,7 @@
-
+
#} {% endblock page_content %} @@ -116,11 +116,11 @@ {% block scripts %} {{ super() }} - + #} {% endblock scripts %} diff --git a/requirements.txt b/requirements.txt index 1c6a94f5..838cee8f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ eventlet Flask==2.1.3 Flask-APScheduler Flask-Assets -Flask-Hashids==1.0.0 +Flask-Hashids==1.0.1 Flask-HTTPAuth Flask-Login Flask-Mail @@ -24,6 +24,6 @@ pyScss python-dotenv pyyaml redis -SQLAlchemy==1.4.46 +SQLAlchemy==1.4.45 tqdm wtforms[email]