From 5837e0502410bad5734d569f0f2b5df72ea063e8 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Wed, 15 Feb 2023 16:17:25 +0100 Subject: [PATCH 001/177] Add routes for CorpusFollower permission management --- app/corpora/routes.py | 99 +++++++++++++++++++++--------------------- app/static/js/Utils.js | 38 ++++++++++++++++ 2 files changed, 87 insertions(+), 50 deletions(-) diff --git a/app/corpora/routes.py b/app/corpora/routes.py index 4b10fa2c..8d2dd45f 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -7,15 +7,21 @@ from flask import ( redirect, render_template, request, - send_from_directory, - url_for + send_from_directory ) 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, User +from app.models import ( + Corpus, + CorpusFile, + CorpusFollowerAssociation, + CorpusFollowPermission, + CorpusStatus, + User +) from . import bp from .forms import ( CreateCorpusFileForm, @@ -24,23 +30,6 @@ from .forms import ( ) -# @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 enable_corpus_is_public(corpus_id): @@ -63,24 +52,22 @@ def disable_corpus_is_public(corpus_id): return '', 204 -# @bp.route('//follow', methods=['GET', 'POST']) +# @bp.route('//follow/') # @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 +# def follow_corpus(corpus_id, 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('//unfollow', methods=['GET', 'POST']) @@ -99,23 +86,35 @@ def unfollow_corpus(corpus_id): user.unfollow_corpus(corpus) db.session.commit() flash(f'You are not following {corpus.title} anymore', category='corpus') - return {}, 202 + return '', 204 -# @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('//followers//permissions/add', methods=['POST']) +def add_permission(corpus_id, user_id, permission): + corpus_follow_association = CorpusFollowerAssociation.query.filter_by(followed_corpus_id=corpus_id, following_user_id=user_id).first_or_404() + permission = request.args.get('permission', type=int) + corpus = corpus_follow_association.followed_corpus + if not (corpus.user == current_user or current_user.is_administrator()): + abort(403) + if permission is None or permission not in iter(CorpusFollowPermission): + abort(400) + corpus_follow_association.add_permission(permission) + db.session.commit() + return '', 204 -# @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('//followers//permissions/remove', methods=['POST']) +def remove_permission(corpus_id, user_id, permission): + corpus_follow_association = CorpusFollowerAssociation.query.filter_by(followed_corpus_id=corpus_id, following_user_id=user_id).first_or_404() + permission = request.args.get('permission') + corpus = corpus_follow_association.followed_corpus + if not (corpus.user == current_user or current_user.is_administrator()): + abort(403) + if permission is None or permission not in iter(CorpusFollowPermission): + abort(400) + corpus_follow_association.remove_permission(permission) + db.session.commit() + return '', 204 @bp.route('/public') diff --git a/app/static/js/Utils.js b/app/static/js/Utils.js index aee382a0..e2ac84ab 100644 --- a/app/static/js/Utils.js +++ b/app/static/js/Utils.js @@ -69,6 +69,44 @@ class Utils { return Utils.mergeObjectsDeep(mergedObject, ...objects.slice(2)); } + static addCorpusFollowerPermissionRequest(corpusId, followerId, permission) { + return new Promise((resolve, reject) => { + fetch(`/corpora/${corpusId}/followers/${followerId}/add_permission?permission=${permission}`, {method: 'POST', headers: {Accept: 'application/json'}}) + .then( + (response) => { + if (response.status === 400) {app.flash('Bad Request', 'error'); reject(response);} + if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);} + if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);} + app.flash(`Permission added`, 'corpus'); + resolve(response); + }, + (response) => { + app.flash('Something went wrong', 'error'); + reject(response); + } + ); + }); + } + + static removeCorpusFollowerPermissionRequest(corpusId, followerId, permission) { + return new Promise((resolve, reject) => { + fetch(`/corpora/${corpusId}/followers/${followerId}/remove_permission?permission=${permission}`, {method: 'POST', headers: {Accept: 'application/json'}}) + .then( + (response) => { + if (response.status === 400) {app.flash('Bad Request', 'error'); reject(response);} + if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);} + if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);} + app.flash(`Permission removed`, 'corpus'); + resolve(response); + }, + (response) => { + app.flash('Something went wrong', 'error'); + reject(response); + } + ); + }); + } + static enableCorpusIsPublicRequest(userId, corpusId) { return new Promise((resolve, reject) => { let corpus; From 2dc41fd3871a3624362c9a19e8e16b06390fe09c Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Mon, 20 Feb 2023 10:40:33 +0100 Subject: [PATCH 002/177] Add CorpusFollowerList and functions --- app/corpora/routes.py | 38 ++--- app/models.py | 34 +++- .../js/ResourceLists/CorpusFollowerList.js | 158 ++++++++++++++++++ app/static/js/ResourceLists/ResourceList.js | 1 + app/static/js/Utils.js | 14 +- app/static/js_new/App.js | 10 ++ app/templates/_scripts.html.j2 | 1 + app/templates/corpora/corpus.html.j2 | 16 +- 8 files changed, 234 insertions(+), 38 deletions(-) create mode 100644 app/static/js/ResourceLists/CorpusFollowerList.js create mode 100644 app/static/js_new/App.js diff --git a/app/corpora/routes.py b/app/corpora/routes.py index 8d2dd45f..a3aca488 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -18,7 +18,7 @@ from app.models import ( Corpus, CorpusFile, CorpusFollowerAssociation, - CorpusFollowPermission, + CorpusFollowerPermission, CorpusStatus, User ) @@ -30,7 +30,7 @@ from .forms import ( ) -@bp.route('//enable_is_public', methods=['POST']) +@bp.route('//is_public/enable', methods=['POST']) @login_required def enable_corpus_is_public(corpus_id): corpus = Corpus.query.get_or_404(corpus_id) @@ -41,7 +41,7 @@ def enable_corpus_is_public(corpus_id): return '', 204 -@bp.route('//disable_is_public', methods=['POST']) +@bp.route('//is_public/disable', methods=['POST']) @login_required def disable_corpus_is_public(corpus_id): corpus = Corpus.query.get_or_404(corpus_id) @@ -89,30 +89,30 @@ def unfollow_corpus(corpus_id): return '', 204 -@bp.route('//followers//permissions/add', methods=['POST']) -def add_permission(corpus_id, user_id, permission): - corpus_follow_association = CorpusFollowerAssociation.query.filter_by(followed_corpus_id=corpus_id, following_user_id=user_id).first_or_404() - permission = request.args.get('permission', type=int) - corpus = corpus_follow_association.followed_corpus +@bp.route('//followers//permissions//add', methods=['POST']) +def add_permission(corpus_id, corpus_follower_id, permission_name): + if permission_name not in [x.name for x in CorpusFollowerPermission]: + abort(400) + corpus_follower_association = CorpusFollowerAssociation.query.filter_by(followed_corpus_id=corpus_id, id=corpus_follower_id).first_or_404() + corpus = corpus_follower_association.followed_corpus if not (corpus.user == current_user or current_user.is_administrator()): abort(403) - if permission is None or permission not in iter(CorpusFollowPermission): - abort(400) - corpus_follow_association.add_permission(permission) + permission_value = CorpusFollowerPermission[permission_name].value + corpus_follower_association.add_permission(permission_value) db.session.commit() return '', 204 -@bp.route('//followers//permissions/remove', methods=['POST']) -def remove_permission(corpus_id, user_id, permission): - corpus_follow_association = CorpusFollowerAssociation.query.filter_by(followed_corpus_id=corpus_id, following_user_id=user_id).first_or_404() - permission = request.args.get('permission') - corpus = corpus_follow_association.followed_corpus +@bp.route('//followers//permissions//remove', methods=['POST']) +def remove_permission(corpus_id, corpus_follower_id, permission_name): + if permission_name not in [x.name for x in CorpusFollowerPermission]: + abort(400) + corpus_follower_association = CorpusFollowerAssociation.query.filter_by(followed_corpus_id=corpus_id, id=corpus_follower_id).first_or_404() + corpus = corpus_follower_association.followed_corpus if not (corpus.user == current_user or current_user.is_administrator()): abort(403) - if permission is None or permission not in iter(CorpusFollowPermission): - abort(400) - corpus_follow_association.remove_permission(permission) + permission_value = CorpusFollowerPermission[permission_name].value + corpus_follower_association.remove_permission(permission_value) db.session.commit() return '', 204 diff --git a/app/models.py b/app/models.py index 4c7a1362..314ebf2a 100644 --- a/app/models.py +++ b/app/models.py @@ -69,7 +69,8 @@ class ProfilePrivacySettings(IntEnum): SHOW_LAST_SEEN = 2 SHOW_MEMBER_SINCE = 4 -class CorpusFollowPermission(IntEnum): + +class CorpusFollowerPermission(IntEnum): VIEW = 1 CONTRIBUTE = 2 ADMINISTRATE = 4 @@ -199,7 +200,10 @@ class Role(HashidMixin, db.Model): 'id': self.hashid, 'default': self.default, 'name': self.name, - 'permissions': self.permissions + 'permissions': [ + x.name for x in Permission + if self.has_permission(x.value) + ] } if relationships: json_serializeable['users'] = { @@ -287,7 +291,7 @@ class Avatar(HashidMixin, FileMixin, db.Model): return json_serializeable -class CorpusFollowerAssociation(db.Model): +class CorpusFollowerAssociation(HashidMixin, db.Model): __tablename__ = 'corpus_follower_associations' # Primary key id = db.Column(db.Integer, primary_key=True) @@ -314,6 +318,19 @@ class CorpusFollowerAssociation(db.Model): if self.has_permission(permission): self.permissions -= permission + def to_json_serializeable(self, backrefs=False, relationships=False): + json_serializeable = { + 'id': self.hashid, + 'permissions': [ + x.name for x in CorpusFollowerPermission + if self.has_permission(x.value) + ], + 'followed_corpus': self.followed_corpus.to_json_serializeable(), + 'following_user': self.following_user.to_json_serializeable() + } + return json_serializeable + + class User(HashidMixin, UserMixin, db.Model): __tablename__ = 'users' # Primary key @@ -634,6 +651,10 @@ class User(HashidMixin, UserMixin, db.Model): json_serializeable['role'] = \ self.role.to_json_serializeable(backrefs=True) if relationships: + json_serializeable['followed_corpus_associations'] = { + x.hashid: x.to_json_serializeable() + for x in self.followed_corpus_associations + } json_serializeable['corpora'] = { x.hashid: x.to_json_serializeable(relationships=True) for x in self.corpora @@ -1403,8 +1424,13 @@ class Corpus(HashidMixin, db.Model): 'is_public': self.is_public } if backrefs: - json_serializeable['user'] = self.user.to_json_serializeable(backrefs=True) + json_serializeable['user'] = \ + self.user.to_json_serializeable(backrefs=True) if relationships: + json_serializeable['following_user_associations'] = { + x.hashid: x.to_json_serializeable() + for x in self.following_user_associations + } json_serializeable['files'] = { x.hashid: x.to_json_serializeable(relationships=True) for x in self.files diff --git a/app/static/js/ResourceLists/CorpusFollowerList.js b/app/static/js/ResourceLists/CorpusFollowerList.js new file mode 100644 index 00000000..cc7471d0 --- /dev/null +++ b/app/static/js/ResourceLists/CorpusFollowerList.js @@ -0,0 +1,158 @@ +class CorpusFollowerList extends ResourceList { + static autoInit() { + for (let corpusFollowerListElement of document.querySelectorAll('.corpus-follower-list:not(.no-autoinit)')) { + new CorpusFollowerList(corpusFollowerListElement); + } + } + + constructor(listContainerElement, options = {}) { + super(listContainerElement, options); + this.listjs.list.addEventListener('change', (event) => {this.onChange(event)}); + this.listjs.list.addEventListener('click', (event) => {this.onClick(event)}); + 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);} + }); + }); + app.getUser(this.userId).then((user) => { + this.add(Object.values(user.corpora[this.corpusId].following_user_associations)); + this.isInitialized = true; + }); + } + + get item() { + return (values) => { + return ` + + user-image + +
+ + +
+ +
+ + + + delete + send + + + `.trim(); + } + } + + get valueNames() { + return [ + {data: ['id']}, + {name: 'avatar', attr: 'src'}, + 'username', + 'about-me', + 'full-name' + ]; + } + + initListContainerElement() { + if (!this.listContainerElement.hasAttribute('id')) { + this.listContainerElement.id = Utils.generateElementId('corpus-follower-list-'); + } + let listSearchElementId = Utils.generateElementId(`${this.listContainerElement.id}-search-`); + this.listContainerElement.innerHTML = ` +
+ search + + +
+ + + + + + + + + + + +
UsernameUser detailsPermissions
+
    + `.trim(); + } + + mapResourceToValue(corpusFollowerAssociation) { + let user = corpusFollowerAssociation.following_user; + return { + 'id': corpusFollowerAssociation.id, + 'avatar': user.avatar ? `/users/${user.id}/avatar` : '/static/images/user_avatar.png', + 'username': user.username, + 'full-name': user.full_name ? user.full_name : '', + 'about-me': user.about_me ? user.about_me : '', + 'permissions-can-VIEW': corpusFollowerAssociation.permissions.includes('VIEW'), + 'permissions-can-CONTRIBUTE': corpusFollowerAssociation.permissions.includes('CONTRIBUTE'), + 'permissions-can-ADMINISTRATE': corpusFollowerAssociation.permissions.includes('ADMINISTRATE') + }; + } + + sort() { + this.listjs.sort('username', {order: 'desc'}); + } + + onChange(event) { + if (event.target.tagName !== 'INPUT') {return;} + let listItemElement = event.target.closest('.list-item[data-id]'); + if (listItemElement === null) {return;} + let itemId = listItemElement.dataset.id; + let listActionElement = event.target.closest('.list-action-trigger[data-list-action]'); + if (listActionElement === null) {return;} + let listAction = listActionElement.dataset.listAction; + switch (listAction) { + case 'toggle-permission-view': { + if (event.target.checked) { + Utils.addCorpusFollowerPermissionRequest(this.userId, this.corpusId, itemId, 'VIEW'); + } else { + Utils.removeCorpusFollowerPermissionRequest(this.userId, this.corpusId, itemId, 'VIEW'); + } + break; + } + case 'toggle-permission-contribute': { + if (event.target.checked) { + Utils.addCorpusFollowerPermissionRequest(this.userId, this.corpusId, itemId, 'CONTRIBUTE'); + } else { + Utils.removeCorpusFollowerPermissionRequest(this.userId, this.corpusId, itemId, 'CONTRIBUTE'); + } + break; + } + case 'toggle-permission-administrate': { + if (event.target.checked) { + Utils.addCorpusFollowerPermissionRequest(this.userId, this.corpusId, itemId, 'ADMINISTRATE'); + } else { + Utils.removeCorpusFollowerPermissionRequest(this.userId, this.corpusId, itemId, 'ADMINISTRATE'); + } + break; + } + default: { + break; + } + } + } + + onClick(event) { + + } + + onPatch(patch) {} +} diff --git a/app/static/js/ResourceLists/ResourceList.js b/app/static/js/ResourceLists/ResourceList.js index 7fe7dec6..b7445553 100644 --- a/app/static/js/ResourceLists/ResourceList.js +++ b/app/static/js/ResourceLists/ResourceList.js @@ -14,6 +14,7 @@ class ResourceList { TesseractOCRPipelineModelList.autoInit(); UserList.autoInit(); AdminUserList.autoInit(); + CorpusFollowerList.autoInit(); } static defaultOptions = { diff --git a/app/static/js/Utils.js b/app/static/js/Utils.js index e2ac84ab..af72bbe0 100644 --- a/app/static/js/Utils.js +++ b/app/static/js/Utils.js @@ -69,9 +69,9 @@ class Utils { return Utils.mergeObjectsDeep(mergedObject, ...objects.slice(2)); } - static addCorpusFollowerPermissionRequest(corpusId, followerId, permission) { + static addCorpusFollowerPermissionRequest(userId, corpusId, corpusFollowerAssociationId, permission) { return new Promise((resolve, reject) => { - fetch(`/corpora/${corpusId}/followers/${followerId}/add_permission?permission=${permission}`, {method: 'POST', headers: {Accept: 'application/json'}}) + fetch(`/corpora/${corpusId}/followers/${corpusFollowerAssociationId}/permissions/${permission}/add`, {method: 'POST', headers: {Accept: 'application/json'}}) .then( (response) => { if (response.status === 400) {app.flash('Bad Request', 'error'); reject(response);} @@ -88,9 +88,9 @@ class Utils { }); } - static removeCorpusFollowerPermissionRequest(corpusId, followerId, permission) { + static removeCorpusFollowerPermissionRequest(userId, corpusId, corpusFollowerAssociationId, permission) { return new Promise((resolve, reject) => { - fetch(`/corpora/${corpusId}/followers/${followerId}/remove_permission?permission=${permission}`, {method: 'POST', headers: {Accept: 'application/json'}}) + fetch(`/corpora/${corpusId}/followers/${corpusFollowerAssociationId}/permissions/${permission}/remove`, {method: 'POST', headers: {Accept: 'application/json'}}) .then( (response) => { if (response.status === 400) {app.flash('Bad Request', 'error'); reject(response);} @@ -104,7 +104,7 @@ class Utils { reject(response); } ); - }); + }); } static enableCorpusIsPublicRequest(userId, corpusId) { @@ -145,7 +145,7 @@ class Utils { 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'}}) + fetch(`/corpora/${corpusId}/is_public/enable`, {method: 'POST', headers: {Accept: 'application/json'}}) .then( (response) => { if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);} @@ -173,7 +173,7 @@ class Utils { } let corpusTitle = corpus?.title; - fetch(`/corpora/${corpusId}/disable_is_public`, {method: 'POST', headers: {Accept: 'application/json'}}) + fetch(`/corpora/${corpusId}/is_public/disable`, {method: 'POST', headers: {Accept: 'application/json'}}) .then( (response) => { if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);} diff --git a/app/static/js_new/App.js b/app/static/js_new/App.js new file mode 100644 index 00000000..8356642d --- /dev/null +++ b/app/static/js_new/App.js @@ -0,0 +1,10 @@ +import DataStore from './DataStore'; +import EventBroker from './EventBroker'; + + +const dataStore = new DataStore(); +const eventBroker = new EventBroker(); +const socket = io({transports: ['websocket'], upgrade: false}); + + +export {eventBroker, socket}; diff --git a/app/templates/_scripts.html.j2 b/app/templates/_scripts.html.j2 index 53a0469c..2a3626ab 100644 --- a/app/templates/_scripts.html.j2 +++ b/app/templates/_scripts.html.j2 @@ -30,6 +30,7 @@ 'js/ResourceLists/TesseractOCRPipelineModelList.js', 'js/ResourceLists/UserList.js', 'js/ResourceLists/AdminUserList.js', + 'js/ResourceLists/CorpusFollowerList.js', 'js/XMLtoObject.js' %} diff --git a/app/templates/corpora/corpus.html.j2 b/app/templates/corpora/corpus.html.j2 index 34228347..30e40e89 100644 --- a/app/templates/corpora/corpus.html.j2 +++ b/app/templates/corpora/corpus.html.j2 @@ -6,8 +6,8 @@ {% block page_content %}
    -
    -
    +
    +

    @@ -80,21 +80,21 @@
    - {#
    + @@ -103,10 +103,10 @@
    Corpus followers -
    +
    -
    #} +
    {% endblock page_content %} From 8168a2384f937919faf6fbb5331e869dd691e35b Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Mon, 20 Feb 2023 16:15:17 +0100 Subject: [PATCH 003/177] Add unfollow and view function to corpusfollowerlist --- .gitignore | 2 + app/corpora/routes.py | 71 +++++++++++-------- app/models.py | 20 +++--- .../js/ResourceLists/CorpusFollowerList.js | 63 +++++++++------- app/static/js/Utils.js | 54 ++++++++++---- app/static/js_new/App.js | 10 --- 6 files changed, 130 insertions(+), 90 deletions(-) delete mode 100644 app/static/js_new/App.js diff --git a/.gitignore b/.gitignore index 59ada396..b7a84431 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ logs/ !logs/dummy *.env +*.pjentsch-testing + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/app/corpora/routes.py b/app/corpora/routes.py index a3aca488..8d321c6f 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -3,6 +3,7 @@ from flask import ( abort, current_app, flash, + make_response, Markup, redirect, render_template, @@ -70,49 +71,59 @@ def disable_corpus_is_public(corpus_id): # return redirect(url_for('.corpus', corpus_id=corpus_id)) -@bp.route('//unfollow', methods=['GET', 'POST']) +@bp.route('//followers//unfollow', methods=['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: +def unfollow_corpus(corpus_id, follower_id): + corpus_follower_association = CorpusFollowerAssociation.query.filter_by(followed_corpus_id=corpus_id, following_user_id=follower_id).first_or_404() + if not (corpus_follower_association.followed_corpus.user == current_user + or corpus_follower_association.following_user == current_user + or current_user.is_administrator()): abort(403) - if user.is_following_corpus(corpus): - user.unfollow_corpus(corpus) + if not corpus_follower_association.following_user.is_following_corpus(corpus_follower_association.followed_corpus): + abort(409) # 'User is not following the corpus' + corpus_follower_association.following_user.unfollow_corpus(corpus_follower_association.followed_corpus) + db.session.commit() + return '', 204 + + +@bp.route('//unfollow', methods=['POST']) +@login_required +def current_user_unfollow_corpus(corpus_id): + corpus = Corpus.query.get_or_404(corpus_id) + if not current_user.is_following_corpus(corpus): + abort(409) # 'You are not following the corpus' + current_user.unfollow_corpus(corpus) db.session.commit() flash(f'You are not following {corpus.title} anymore', category='corpus') return '', 204 -@bp.route('//followers//permissions//add', methods=['POST']) -def add_permission(corpus_id, corpus_follower_id, permission_name): - if permission_name not in [x.name for x in CorpusFollowerPermission]: - abort(400) - corpus_follower_association = CorpusFollowerAssociation.query.filter_by(followed_corpus_id=corpus_id, id=corpus_follower_id).first_or_404() - corpus = corpus_follower_association.followed_corpus - if not (corpus.user == current_user or current_user.is_administrator()): +@bp.route('//followers//permissions//add', methods=['POST']) +def add_permission(corpus_id, follower_id, permission_name): + try: + permission = CorpusFollowerPermission[permission_name] + except KeyError: + abort(409) # 'Permission "{permission_name}" does not exist' + corpus_follower_association = CorpusFollowerAssociation.query.filter_by(followed_corpus_id=corpus_id, following_user_id=follower_id).first_or_404() + if not (corpus_follower_association.followed_corpus.user == current_user + or current_user.is_administrator()): abort(403) - permission_value = CorpusFollowerPermission[permission_name].value - corpus_follower_association.add_permission(permission_value) + corpus_follower_association.add_permission(permission) db.session.commit() return '', 204 -@bp.route('//followers//permissions//remove', methods=['POST']) -def remove_permission(corpus_id, corpus_follower_id, permission_name): - if permission_name not in [x.name for x in CorpusFollowerPermission]: - abort(400) - corpus_follower_association = CorpusFollowerAssociation.query.filter_by(followed_corpus_id=corpus_id, id=corpus_follower_id).first_or_404() - corpus = corpus_follower_association.followed_corpus - if not (corpus.user == current_user or current_user.is_administrator()): +@bp.route('//followers//permissions//remove', methods=['POST']) +def remove_permission(corpus_id, follower_id, permission_name): + try: + permission = CorpusFollowerPermission[permission_name] + except KeyError: + return make_response(f'Permission "{permission_name}" does not exist', 409) + corpus_follower_association = CorpusFollowerAssociation.query.filter_by(followed_corpus_id=corpus_id, following_user_id=follower_id).first_or_404() + if not (corpus_follower_association.followed_corpus.user == current_user + or current_user.is_administrator()): abort(403) - permission_value = CorpusFollowerPermission[permission_name].value - corpus_follower_association.remove_permission(permission_value) + corpus_follower_association.remove_permission(permission) db.session.commit() return '', 204 diff --git a/app/models.py b/app/models.py index 314ebf2a..81da0eb3 100644 --- a/app/models.py +++ b/app/models.py @@ -307,23 +307,23 @@ class CorpusFollowerAssociation(HashidMixin, db.Model): def __repr__(self): return f'' - def has_permission(self, permission): - return self.permissions & permission == permission + def has_permission(self, permission: CorpusFollowerPermission): + return self.permissions & permission.value == permission.value - def add_permission(self, permission): + def add_permission(self, permission: CorpusFollowerPermission): if not self.has_permission(permission): - self.permissions += permission + self.permissions += permission.value - def remove_permission(self, permission): + def remove_permission(self, permission: CorpusFollowerPermission): if self.has_permission(permission): - self.permissions -= permission + self.permissions -= permission.value def to_json_serializeable(self, backrefs=False, relationships=False): json_serializeable = { 'id': self.hashid, 'permissions': [ x.name for x in CorpusFollowerPermission - if self.has_permission(x.value) + if self.has_permission(x) ], 'followed_corpus': self.followed_corpus.to_json_serializeable(), 'following_user': self.following_user.to_json_serializeable() @@ -370,7 +370,8 @@ class User(HashidMixin, UserMixin, db.Model): ) followed_corpus_associations = db.relationship( 'CorpusFollowerAssociation', - back_populates='following_user' + back_populates='following_user', + cascade='all, delete-orphan' ) followed_corpora = association_proxy( 'followed_corpus_associations', @@ -1320,7 +1321,8 @@ class Corpus(HashidMixin, db.Model): ) following_user_associations = db.relationship( 'CorpusFollowerAssociation', - back_populates='followed_corpus' + back_populates='followed_corpus', + cascade='all, delete-orphan' ) following_users = association_proxy( 'following_user_associations', diff --git a/app/static/js/ResourceLists/CorpusFollowerList.js b/app/static/js/ResourceLists/CorpusFollowerList.js index cc7471d0..711d5111 100644 --- a/app/static/js/ResourceLists/CorpusFollowerList.js +++ b/app/static/js/ResourceLists/CorpusFollowerList.js @@ -33,22 +33,22 @@ class CorpusFollowerList extends ResourceList {


    - delete + delete send @@ -59,6 +59,7 @@ class CorpusFollowerList extends ResourceList { get valueNames() { return [ {data: ['id']}, + {data: ['follower-id']}, {name: 'avatar', attr: 'src'}, 'username', 'about-me', @@ -97,13 +98,14 @@ class CorpusFollowerList extends ResourceList { let user = corpusFollowerAssociation.following_user; return { 'id': corpusFollowerAssociation.id, + 'follower-id': user.id, 'avatar': user.avatar ? `/users/${user.id}/avatar` : '/static/images/user_avatar.png', 'username': user.username, 'full-name': user.full_name ? user.full_name : '', 'about-me': user.about_me ? user.about_me : '', - 'permissions-can-VIEW': corpusFollowerAssociation.permissions.includes('VIEW'), - 'permissions-can-CONTRIBUTE': corpusFollowerAssociation.permissions.includes('CONTRIBUTE'), - 'permissions-can-ADMINISTRATE': corpusFollowerAssociation.permissions.includes('ADMINISTRATE') + 'permission-can-VIEW': corpusFollowerAssociation.permissions.includes('VIEW'), + 'permission-can-CONTRIBUTE': corpusFollowerAssociation.permissions.includes('CONTRIBUTE'), + 'permission-can-ADMINISTRATE': corpusFollowerAssociation.permissions.includes('ADMINISTRATE') }; } @@ -120,27 +122,15 @@ class CorpusFollowerList extends ResourceList { if (listActionElement === null) {return;} let listAction = listActionElement.dataset.listAction; switch (listAction) { - case 'toggle-permission-view': { + case 'toggle-permission': { + let followerId = listItemElement.dataset.followerId; + let permission = listActionElement.dataset.permission; if (event.target.checked) { - Utils.addCorpusFollowerPermissionRequest(this.userId, this.corpusId, itemId, 'VIEW'); + Utils.addCorpusFollowerPermissionRequest(this.corpusId, followerId, permission) + .catch((error) => {event.target.checked = !event.target.checked;}); } else { - Utils.removeCorpusFollowerPermissionRequest(this.userId, this.corpusId, itemId, 'VIEW'); - } - break; - } - case 'toggle-permission-contribute': { - if (event.target.checked) { - Utils.addCorpusFollowerPermissionRequest(this.userId, this.corpusId, itemId, 'CONTRIBUTE'); - } else { - Utils.removeCorpusFollowerPermissionRequest(this.userId, this.corpusId, itemId, 'CONTRIBUTE'); - } - break; - } - case 'toggle-permission-administrate': { - if (event.target.checked) { - Utils.addCorpusFollowerPermissionRequest(this.userId, this.corpusId, itemId, 'ADMINISTRATE'); - } else { - Utils.removeCorpusFollowerPermissionRequest(this.userId, this.corpusId, itemId, 'ADMINISTRATE'); + Utils.removeCorpusFollowerPermissionRequest(this.corpusId, followerId, permission) + .catch((error) => {event.target.checked = !event.target.checked;}); } break; } @@ -151,7 +141,26 @@ class CorpusFollowerList extends ResourceList { } onClick(event) { - + let listItemElement = event.target.closest('.list-item[data-id]'); + if (listItemElement === null) {return;} + let itemId = listItemElement.dataset.id; + let listActionElement = event.target.closest('.list-action-trigger[data-list-action]'); + let listAction = listActionElement === null ? 'view' : listActionElement.dataset.listAction; + switch (listAction) { + case 'unfollow-request': { + let followerId = listItemElement.dataset.followerId; + Utils.unfollowCorpusRequest(this.corpusId, followerId); + break; + } + case 'view': { + let followerId = listItemElement.dataset.followerId; + window.location.href = `/users/${followerId}`; + break; + } + default: { + break; + } + } } onPatch(patch) {} diff --git a/app/static/js/Utils.js b/app/static/js/Utils.js index af72bbe0..547f29b5 100644 --- a/app/static/js/Utils.js +++ b/app/static/js/Utils.js @@ -69,16 +69,19 @@ class Utils { return Utils.mergeObjectsDeep(mergedObject, ...objects.slice(2)); } - static addCorpusFollowerPermissionRequest(userId, corpusId, corpusFollowerAssociationId, permission) { + static addCorpusFollowerPermissionRequest(corpusId, followerId, permission) { return new Promise((resolve, reject) => { - fetch(`/corpora/${corpusId}/followers/${corpusFollowerAssociationId}/permissions/${permission}/add`, {method: 'POST', headers: {Accept: 'application/json'}}) + fetch(`/corpora/${corpusId}/followers/${followerId}/permissions/${permission}/add`, {method: 'POST', headers: {Accept: 'application/json'}}) .then( (response) => { - if (response.status === 400) {app.flash('Bad Request', 'error'); reject(response);} - if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);} - if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);} - app.flash(`Permission added`, 'corpus'); - resolve(response); + if (response.ok) { + app.flash(`Permission added`, 'corpus'); + resolve(response); + return; + } else { + app.flash(`${response.statusText}`, 'error'); + reject(response); + } }, (response) => { app.flash('Something went wrong', 'error'); @@ -88,16 +91,18 @@ class Utils { }); } - static removeCorpusFollowerPermissionRequest(userId, corpusId, corpusFollowerAssociationId, permission) { + static removeCorpusFollowerPermissionRequest(corpusId, followerId, permission) { return new Promise((resolve, reject) => { - fetch(`/corpora/${corpusId}/followers/${corpusFollowerAssociationId}/permissions/${permission}/remove`, {method: 'POST', headers: {Accept: 'application/json'}}) + fetch(`/corpora/${corpusId}/followers/${followerId}/permissions/${permission}/remove`, {method: 'POST', headers: {Accept: 'application/json'}}) .then( (response) => { - if (response.status === 400) {app.flash('Bad Request', 'error'); reject(response);} - if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);} - if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);} - app.flash(`Permission removed`, 'corpus'); - resolve(response); + if (response.ok) { + app.flash(`Permission removed`, 'corpus'); + resolve(response); + } else { + app.flash(`${response.statusText}`, 'error'); + reject(response); + } }, (response) => { app.flash('Something went wrong', 'error'); @@ -215,6 +220,27 @@ class Utils { }); } + static unfollowCorpusRequest(corpusId, followerId) { + return new Promise((resolve, reject) => { + fetch(`/corpora/${corpusId}/followers/${followerId}/unfollow`, {method: 'POST', headers: {Accept: 'application/json'}}) + .then( + (response) => { + if (response.ok) { + app.flash(`User unfollowed from Corpus`, 'corpus'); + resolve(response); + } else { + app.flash(`${response.statusText}`, 'error'); + reject(response); + } + }, + (response) => { + app.flash('Something went wrong', 'error'); + reject(response); + } + ); + }); + } + static deleteCorpusRequest(userId, corpusId) { return new Promise((resolve, reject) => { let corpus; diff --git a/app/static/js_new/App.js b/app/static/js_new/App.js deleted file mode 100644 index 8356642d..00000000 --- a/app/static/js_new/App.js +++ /dev/null @@ -1,10 +0,0 @@ -import DataStore from './DataStore'; -import EventBroker from './EventBroker'; - - -const dataStore = new DataStore(); -const eventBroker = new EventBroker(); -const socket = io({transports: ['websocket'], upgrade: false}); - - -export {eventBroker, socket}; From 8d70e9385664a101b2d6e390440183fde02a8b29 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Tue, 21 Feb 2023 11:05:09 +0100 Subject: [PATCH 004/177] Update CorpusFollowerAssociation table --- app/corpora/routes.py | 24 +++++----- app/models.py | 44 +++++++++---------- .../js/ResourceLists/CorpusFollowerList.js | 44 +++++++++++-------- migrations/versions/03c7211f089d_.py | 42 ++++++++++++++++++ 4 files changed, 100 insertions(+), 54 deletions(-) create mode 100644 migrations/versions/03c7211f089d_.py diff --git a/app/corpora/routes.py b/app/corpora/routes.py index 8d321c6f..17d203f1 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -74,15 +74,15 @@ def disable_corpus_is_public(corpus_id): @bp.route('//followers//unfollow', methods=['POST']) @login_required def unfollow_corpus(corpus_id, follower_id): - corpus_follower_association = CorpusFollowerAssociation.query.filter_by(followed_corpus_id=corpus_id, following_user_id=follower_id).first_or_404() - if not (corpus_follower_association.followed_corpus.user == current_user - or corpus_follower_association.following_user == current_user - or current_user.is_administrator()): + corpus = Corpus.query.get_or_404(corpus_id) + follower = User.query.get_or_404(follower_id) + if not (corpus.user == current_user or follower == current_user or current_user.is_administrator()): abort(403) - if not corpus_follower_association.following_user.is_following_corpus(corpus_follower_association.followed_corpus): + if not follower.is_following_corpus(corpus): abort(409) # 'User is not following the corpus' - corpus_follower_association.following_user.unfollow_corpus(corpus_follower_association.followed_corpus) + follower.unfollow_corpus(corpus) db.session.commit() + flash(f'{follower.username} is not following {corpus.title} anymore', category='corpus') return '', 204 @@ -103,10 +103,9 @@ def add_permission(corpus_id, follower_id, permission_name): try: permission = CorpusFollowerPermission[permission_name] except KeyError: - abort(409) # 'Permission "{permission_name}" does not exist' - corpus_follower_association = CorpusFollowerAssociation.query.filter_by(followed_corpus_id=corpus_id, following_user_id=follower_id).first_or_404() - if not (corpus_follower_association.followed_corpus.user == current_user - or current_user.is_administrator()): + abort(409) # f'Permission "{permission_name}" does not exist' + corpus_follower_association = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=follower_id).first_or_404() + if not (corpus_follower_association.corpus.user == current_user or current_user.is_administrator()): abort(403) corpus_follower_association.add_permission(permission) db.session.commit() @@ -119,9 +118,8 @@ def remove_permission(corpus_id, follower_id, permission_name): permission = CorpusFollowerPermission[permission_name] except KeyError: return make_response(f'Permission "{permission_name}" does not exist', 409) - corpus_follower_association = CorpusFollowerAssociation.query.filter_by(followed_corpus_id=corpus_id, following_user_id=follower_id).first_or_404() - if not (corpus_follower_association.followed_corpus.user == current_user - or current_user.is_administrator()): + corpus_follower_association = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=follower_id).first_or_404() + if not (corpus_follower_association.corpus.user == current_user or current_user.is_administrator()): abort(403) corpus_follower_association.remove_permission(permission) db.session.commit() diff --git a/app/models.py b/app/models.py index 81da0eb3..5e2bdcdf 100644 --- a/app/models.py +++ b/app/models.py @@ -296,16 +296,16 @@ class CorpusFollowerAssociation(HashidMixin, db.Model): # Primary key id = db.Column(db.Integer, primary_key=True) # Foreign keys - following_user_id = db.Column(db.Integer, db.ForeignKey('users.id')) - followed_corpus_id = db.Column(db.Integer, db.ForeignKey('corpora.id')) + corpus_id = db.Column(db.Integer, db.ForeignKey('corpora.id')) + follower_id = db.Column(db.Integer, db.ForeignKey('users.id')) # Fields permissions = db.Column(db.Integer, default=0, nullable=False) # Relationships - followed_corpus = db.relationship('Corpus', back_populates='following_user_associations') - following_user = db.relationship('User', back_populates='followed_corpus_associations') + corpus = db.relationship('Corpus', back_populates='corpus_follower_associations') + follower = db.relationship('User', back_populates='corpus_follower_associations') def __repr__(self): - return f'' + return f'' def has_permission(self, permission: CorpusFollowerPermission): return self.permissions & permission.value == permission.value @@ -325,8 +325,8 @@ class CorpusFollowerAssociation(HashidMixin, db.Model): x.name for x in CorpusFollowerPermission if self.has_permission(x) ], - 'followed_corpus': self.followed_corpus.to_json_serializeable(), - 'following_user': self.following_user.to_json_serializeable() + 'corpus': self.corpus.to_json_serializeable(), + 'follower': self.follower.to_json_serializeable() } return json_serializeable @@ -368,15 +368,15 @@ class User(HashidMixin, UserMixin, db.Model): cascade='all, delete-orphan', lazy='dynamic' ) - followed_corpus_associations = db.relationship( + corpus_follower_associations = db.relationship( 'CorpusFollowerAssociation', - back_populates='following_user', + back_populates='follower', cascade='all, delete-orphan' ) followed_corpora = association_proxy( - 'followed_corpus_associations', - 'followed_corpus', - creator=lambda c: CorpusFollowerAssociation(followed_corpus=c) + 'corpus_follower_associations', + 'corpus', + creator=lambda c: CorpusFollowerAssociation(corpus=c) ) jobs = db.relationship( 'Job', @@ -652,9 +652,9 @@ class User(HashidMixin, UserMixin, db.Model): json_serializeable['role'] = \ self.role.to_json_serializeable(backrefs=True) if relationships: - json_serializeable['followed_corpus_associations'] = { + json_serializeable['corpus_follower_associations'] = { x.hashid: x.to_json_serializeable() - for x in self.followed_corpus_associations + for x in self.corpus_follower_associations } json_serializeable['corpora'] = { x.hashid: x.to_json_serializeable(relationships=True) @@ -1319,15 +1319,15 @@ class Corpus(HashidMixin, db.Model): lazy='dynamic', cascade='all, delete-orphan' ) - following_user_associations = db.relationship( + corpus_follower_associations = db.relationship( 'CorpusFollowerAssociation', - back_populates='followed_corpus', + back_populates='corpus', cascade='all, delete-orphan' ) - following_users = association_proxy( - 'following_user_associations', - 'following_user', - creator=lambda u: CorpusFollowerAssociation(following_user=u) + followers = association_proxy( + 'corpus_follower_associations', + 'follower', + creator=lambda u: CorpusFollowerAssociation(followers=u) ) user = db.relationship('User', back_populates='corpora') # "static" attributes @@ -1429,9 +1429,9 @@ class Corpus(HashidMixin, db.Model): json_serializeable['user'] = \ self.user.to_json_serializeable(backrefs=True) if relationships: - json_serializeable['following_user_associations'] = { + json_serializeable['corpus_follower_associations'] = { x.hashid: x.to_json_serializeable() - for x in self.following_user_associations + for x in self.corpus_follower_associations } json_serializeable['files'] = { x.hashid: x.to_json_serializeable(relationships=True) diff --git a/app/static/js/ResourceLists/CorpusFollowerList.js b/app/static/js/ResourceLists/CorpusFollowerList.js index 711d5111..d83e4a39 100644 --- a/app/static/js/ResourceLists/CorpusFollowerList.js +++ b/app/static/js/ResourceLists/CorpusFollowerList.js @@ -19,7 +19,7 @@ class CorpusFollowerList extends ResourceList { }); }); app.getUser(this.userId).then((user) => { - this.add(Object.values(user.corpora[this.corpusId].following_user_associations)); + this.add(Object.values(user.corpora[this.corpusId].corpus_follower_associations)); this.isInitialized = true; }); } @@ -32,20 +32,26 @@ class CorpusFollowerList extends ResourceList {
    - + + +
    - + + +
    - + + + delete @@ -95,14 +101,13 @@ class CorpusFollowerList extends ResourceList { } mapResourceToValue(corpusFollowerAssociation) { - let user = corpusFollowerAssociation.following_user; return { 'id': corpusFollowerAssociation.id, - 'follower-id': user.id, - 'avatar': user.avatar ? `/users/${user.id}/avatar` : '/static/images/user_avatar.png', - 'username': user.username, - 'full-name': user.full_name ? user.full_name : '', - 'about-me': user.about_me ? user.about_me : '', + 'follower-id': corpusFollowerAssociation.follower.id, + 'avatar': corpusFollowerAssociation.follower.avatar ? `/users/${corpusFollowerAssociation.follower.id}/avatar` : '/static/images/user_avatar.png', + 'username': corpusFollowerAssociation.follower.username, + 'full-name': corpusFollowerAssociation.follower.full_name ? corpusFollowerAssociation.follower.full_name : '', + 'about-me': corpusFollowerAssociation.follower.about_me ? corpusFollowerAssociation.follower.about_me : '', 'permission-can-VIEW': corpusFollowerAssociation.permissions.includes('VIEW'), 'permission-can-CONTRIBUTE': corpusFollowerAssociation.permissions.includes('CONTRIBUTE'), 'permission-can-ADMINISTRATE': corpusFollowerAssociation.permissions.includes('ADMINISTRATE') @@ -141,6 +146,7 @@ class CorpusFollowerList extends ResourceList { } onClick(event) { + if (event.target.closest('.disable-on-click') !== null) {return;} let listItemElement = event.target.closest('.list-item[data-id]'); if (listItemElement === null) {return;} let itemId = listItemElement.dataset.id; diff --git a/migrations/versions/03c7211f089d_.py b/migrations/versions/03c7211f089d_.py new file mode 100644 index 00000000..2f73e3aa --- /dev/null +++ b/migrations/versions/03c7211f089d_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: 03c7211f089d +Revises: 5fe6a6c7870c +Create Date: 2023-02-21 09:57:22.005883 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '03c7211f089d' +down_revision = '5fe6a6c7870c' +branch_labels = None +depends_on = None + + +def upgrade(): + op.alter_column( + 'corpus_follower_associations', + 'followed_corpus_id', + new_column_name='corpus_id' + ) + op.alter_column( + 'corpus_follower_associations', + 'following_user_id', + new_column_name='follower_id' + ) + + +def downgrade(): + op.alter_column( + 'corpus_follower_associations', + 'corpus_id', + new_column_name='followed_corpus_id' + ) + op.alter_column( + 'corpus_follower_associations', + 'follower_id', + new_column_name='following_user_id' + ) From ff238cd823f559cc246e63364913057a31cf3e6a Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Tue, 21 Feb 2023 11:05:23 +0100 Subject: [PATCH 005/177] Change style of contributionlists --- .../SpacyNLPPipelineModelList.js | 20 ++++++++----------- .../TesseractOCRPipelineModelList.js | 15 +++++++------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/app/static/js/ResourceLists/SpacyNLPPipelineModelList.js b/app/static/js/ResourceLists/SpacyNLPPipelineModelList.js index b90fb06b..f7901528 100644 --- a/app/static/js/ResourceLists/SpacyNLPPipelineModelList.js +++ b/app/static/js/ResourceLists/SpacyNLPPipelineModelList.js @@ -30,14 +30,12 @@ class SpaCyNLPPipelineModelList extends ResourceList {
    ()
    -
    - + -
    + delete @@ -80,6 +78,7 @@ class SpaCyNLPPipelineModelList extends ResourceList { Title and Description Publisher + Availability @@ -111,6 +110,7 @@ class SpaCyNLPPipelineModelList extends ResourceList { } onChange(event) { + if (event.target.tagName !== 'INPUT') {return;} let listItemElement = event.target.closest('.list-item[data-id]'); if (listItemElement === null) {return;} let itemId = listItemElement.dataset.id; @@ -118,7 +118,7 @@ class SpaCyNLPPipelineModelList extends ResourceList { if (listActionElement === null) {return;} let listAction = listActionElement.dataset.listAction; switch (listAction) { - case 'share-request': { + case 'toggle-is-public': { Utils.spaCyNLPPipelineModelToggleIsPublicRequest(this.userId, itemId); break; } @@ -129,15 +129,11 @@ class SpaCyNLPPipelineModelList extends ResourceList { } onClick(event) { + if (event.target.closest('.disable-on-click') !== null) {return;} let listItemElement = event.target.closest('.list-item[data-id]'); if (listItemElement === null) {return;} let itemId = listItemElement.dataset.id; let listActionElement = event.target.closest('.list-action-trigger[data-list-action]'); - // ignore switch clicks, handle them by the onChange method instead - if (listActionElement.classList.contains('switch')) { - event.preventDefault(); - this.onChange(event); - } let listAction = listActionElement === null ? 'view' : listActionElement.dataset.listAction; switch (listAction) { case 'delete-request': { diff --git a/app/static/js/ResourceLists/TesseractOCRPipelineModelList.js b/app/static/js/ResourceLists/TesseractOCRPipelineModelList.js index 4ad3f5b1..c5e08b1d 100644 --- a/app/static/js/ResourceLists/TesseractOCRPipelineModelList.js +++ b/app/static/js/ResourceLists/TesseractOCRPipelineModelList.js @@ -38,14 +38,12 @@ class TesseractOCRPipelineModelList extends ResourceList {
    ()
    -
    - + -
    + delete @@ -89,6 +87,7 @@ class TesseractOCRPipelineModelList extends ResourceList { Title and Description Publisher + Availability @@ -120,6 +119,7 @@ class TesseractOCRPipelineModelList extends ResourceList { } onChange(event) { + if (event.target.tagName !== 'INPUT') {return;} let listItemElement = event.target.closest('.list-item[data-id]'); if (listItemElement === null) {return;} let itemId = listItemElement.dataset.id; @@ -127,7 +127,7 @@ class TesseractOCRPipelineModelList extends ResourceList { if (listActionElement === null) {return;} let listAction = listActionElement.dataset.listAction; switch (listAction) { - case 'share-request': { + case 'toggle-is-public': { Utils.tesseractOCRPipelineModelToggleIsPublicRequest(this.userId, itemId); break; } @@ -138,6 +138,7 @@ class TesseractOCRPipelineModelList extends ResourceList { } onClick(event) { + if (event.target.closest('.disable-on-click') !== null) {return;} let listItemElement = event.target.closest('.list-item[data-id]'); if (listItemElement === null) {return;} let itemId = listItemElement.dataset.id; From d699fd09e59023eec7d09bc064e332a396aaca99 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Tue, 21 Feb 2023 13:59:11 +0100 Subject: [PATCH 006/177] Add live data updates for corpus follower lists --- app/models.py | 45 +++++++++++++++---- .../js/ResourceLists/CorpusFollowerList.js | 33 +++++++++++++- 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/app/models.py b/app/models.py index 5e2bdcdf..4e40793b 100644 --- a/app/models.py +++ b/app/models.py @@ -653,7 +653,7 @@ class User(HashidMixin, UserMixin, db.Model): self.role.to_json_serializeable(backrefs=True) if relationships: json_serializeable['corpus_follower_associations'] = { - x.hashid: x.to_json_serializeable() + x.hashid: x.to_json_serializeable(relationships=True) for x in self.corpus_follower_associations } json_serializeable['corpora'] = { @@ -672,10 +672,6 @@ class User(HashidMixin, UserMixin, db.Model): x.hashid: x.to_json_serializeable(relationships=True) for x in self.spacy_nlp_pipeline_models } - json_serializeable['followed_corpora'] = { - x.hashid: x.to_json_serializeable(relationships=True) - for x in self.followed_corpora - } if filter_by_privacy_settings: if not self.has_profile_privacy_setting(ProfilePrivacySettings.SHOW_EMAIL): @@ -1430,7 +1426,7 @@ class Corpus(HashidMixin, db.Model): self.user.to_json_serializeable(backrefs=True) if relationships: json_serializeable['corpus_follower_associations'] = { - x.hashid: x.to_json_serializeable() + x.hashid: x.to_json_serializeable(relationships=True) for x in self.corpus_follower_associations } json_serializeable['files'] = { @@ -1454,12 +1450,27 @@ class Corpus(HashidMixin, db.Model): @db.event.listens_for(TesseractOCRPipelineModel, 'after_delete') def ressource_after_delete(mapper, connection, ressource): jsonpatch = [{'op': 'remove', 'path': ressource.jsonpatch_path}] - room = f'users.{ressource.user_hashid}' - socketio.emit('users.patch', jsonpatch, room=room) room = f'/users/{ressource.user_hashid}' socketio.emit('PATCH', jsonpatch, room=room) +@db.event.listens_for(CorpusFollowerAssociation, 'after_delete') +def corpus_follower_association_after_delete_handler(mapper, connection, ressource): + corpus_owner_hashid = ressource.corpus.user.hashid + corpus_hashid = hashids.encode(ressource.corpus_id) + follower_hashid = hashids.encode(ressource.follower_id) + # Send a PATCH to the corpus owner + jsonpatch_path = f'/users/{corpus_owner_hashid}/corpora/{corpus_hashid}/corpus_follower_associations/{ressource.hashid}' + jsonpatch = [{'op': 'remove', 'path': jsonpatch_path}] + room = f'/users/{corpus_owner_hashid}' + socketio.emit('PATCH', jsonpatch, room=room) + # Send a PATCH to the follower + jsonpatch_path = f'/users/{follower_hashid}/corpus_follower_associations/{ressource.hashid}' + jsonpatch = [{'op': 'remove', 'path': jsonpatch_path}] + room = f'/users/{follower_hashid}' + socketio.emit('PATCH', jsonpatch, room=room) + + @db.event.listens_for(Corpus, 'after_insert') @db.event.listens_for(CorpusFile, 'after_insert') @db.event.listens_for(Job, 'after_insert') @@ -1478,6 +1489,24 @@ def ressource_after_insert_handler(mapper, connection, ressource): socketio.emit('PATCH', jsonpatch, room=room) +# @db.event.listens_for(CorpusFollowerAssociation, 'after_insert') +# def corpus_follower_association_after_insert_handler(mapper, connection, ressource): +# corpus_owner_hashid = ressource.corpus.user.hashid +# corpus_hashid = hashids.encode(ressource.corpus_id) +# follower_hashid = hashids.encode(ressource.follower_id) +# value = ressource.to_json_serializeable() +# # Send a PATCH to the corpus owner +# jsonpatch_path = f'/users/{corpus_owner_hashid}/corpora/{corpus_hashid}/corpus_follower_associations/{ressource.hashid}' +# jsonpatch = [{'op': 'add', 'path': jsonpatch_path, 'value': value}] +# room = f'/users/{corpus_owner_hashid}' +# socketio.emit('PATCH', jsonpatch, room=room) +# # Send a PATCH to the follower +# jsonpatch_path = f'/users/{follower_hashid}/corpus_follower_associations/{ressource.hashid}' +# jsonpatch = [{'op': 'add', 'path': jsonpatch_path, 'value': value}] +# room = f'/users/{follower_hashid}' +# socketio.emit('PATCH', jsonpatch, room=room) + + @db.event.listens_for(Corpus, 'after_update') @db.event.listens_for(CorpusFile, 'after_update') @db.event.listens_for(Job, 'after_update') diff --git a/app/static/js/ResourceLists/CorpusFollowerList.js b/app/static/js/ResourceLists/CorpusFollowerList.js index d83e4a39..616f3793 100644 --- a/app/static/js/ResourceLists/CorpusFollowerList.js +++ b/app/static/js/ResourceLists/CorpusFollowerList.js @@ -169,5 +169,36 @@ class CorpusFollowerList extends ResourceList { } } - onPatch(patch) {} + onPatch(patch) { + let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/corpus_follower_associations/([A-Za-z0-9]*)`); + let filteredPatch = patch.filter(operation => re.test(operation.path)); + for (let operation of filteredPatch) { + switch(operation.op) { + case 'add': { + // let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/corpus_follower_associations/([A-Za-z0-9]*)$`); + // if (re.test(operation.path)) {this.add(operation.value);} + break; + } + case 'remove': { + let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/corpus_follower_associations/([A-Za-z0-9]*)$`); + if (re.test(operation.path)) { + let [match, jobId] = operation.path.match(re); + this.remove(jobId); + } + break; + } + case 'replace': { + // let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/corpus_follower_associations/([A-Za-z0-9]*)/(service|status|description|title)$`); + // if (re.test(operation.path)) { + // let [match, jobId, valueName] = operation.path.match(re); + // this.replace(jobId, valueName, operation.value); + // } + break; + } + default: { + break; + } + } + } + } } From 726e781692f5971e65ed460e6e8d070326c19e9f Mon Sep 17 00:00:00 2001 From: Inga Kirschnick Date: Tue, 21 Feb 2023 16:18:04 +0100 Subject: [PATCH 007/177] share link generator update --- app/corpora/routes.py | 74 ++++++++++++++++------------ app/static/js/Utils.js | 30 +++++++++++ app/templates/corpora/corpus.html.j2 | 65 +++++++++++++++++------- 3 files changed, 120 insertions(+), 49 deletions(-) diff --git a/app/corpora/routes.py b/app/corpora/routes.py index 8d2dd45f..55de99f3 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta from flask import ( abort, current_app, @@ -7,7 +7,8 @@ from flask import ( redirect, render_template, request, - send_from_directory + send_from_directory, + url_for ) from flask_login import current_user, login_required from threading import Thread @@ -52,22 +53,20 @@ def disable_corpus_is_public(corpus_id): return '', 204 -# @bp.route('//follow/') -# @login_required -# def follow_corpus(corpus_id, 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('//follow/') +@login_required +def follow_corpus(corpus_id, 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 + return redirect(url_for('.corpus', corpus_id=corpus_id)) @bp.route('//unfollow', methods=['GET', 'POST']) @@ -157,27 +156,16 @@ def create_corpus(): ) -@bp.route('/', methods=['GET', 'POST']) +@bp.route('/') @login_required def corpus(corpus_id): corpus = Corpus.query.get_or_404(corpus_id) + exp_date = (datetime.utcnow() + timedelta(days=7)).strftime('%b %d, %Y') 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=corpus, - # token=token, + exp_date=exp_date, title='Corpus' ) if current_user.is_following_corpus(corpus) or corpus.is_public: @@ -190,6 +178,28 @@ def corpus(corpus_id): ) abort(403) +@bp.route('//generate-corpus-share-link', methods=['GET', 'POST']) +@login_required +def generate_corpus_share_link(corpus_id): + data = request.get_json('data') + permission = data['permission'] + expiration = data['expiration'] + corpus = Corpus.query.get_or_404(corpus_id) + now = datetime.utcnow() + payload = { + 'exp': expiration, + 'iat': now, + 'iss': current_app.config['SERVER_NAME'], + 'sub': permission + } + token = jwt.encode( + payload, + current_app.config['SECRET_KEY'], + algorithm='HS256' + ) + link = url_for('corpora.follow_corpus', corpus_id=corpus_id, token=token, _external=True) + return link + @bp.route('/', methods=['DELETE']) @login_required diff --git a/app/static/js/Utils.js b/app/static/js/Utils.js index e2ac84ab..b3da4ca1 100644 --- a/app/static/js/Utils.js +++ b/app/static/js/Utils.js @@ -751,4 +751,34 @@ class Utils { ); }); } + + static generateCorpusShareLinkRequest(corpusId, permission, expiration) { + return new Promise((resolve, reject) => { + const data = {permission: permission, expiration: expiration}; + fetch(`/corpora/${corpusId}/generate-corpus-share-link`, {method: 'POST', headers: {Accept: 'text/plain'}, body: JSON.stringify(data)}) + .then( + (response) => { + if (!response.ok) { + app.flash(`Something went wrong: ${response.status} ${response.statusText}`, 'error'); + reject(response); + return; + } + return response.text(); + }, + (response) => { + // Something went wrong during the HTTP request + app.flash('Something went wrong', 'error'); + reject(response); + } + ) + .then( + (corpusShareLink) => {resolve(corpusShareLink);}, + (error) => { + // Something went wrong during ReadableStream processing + app.flash('Something went wrong', 'error'); + reject(error); + } + ); + }); + } } diff --git a/app/templates/corpora/corpus.html.j2 b/app/templates/corpora/corpus.html.j2 index 34228347..c84d6c0e 100644 --- a/app/templates/corpora/corpus.html.j2 +++ b/app/templates/corpora/corpus.html.j2 @@ -80,10 +80,13 @@
    - {#
    +
    -
    + Share your Corpus +
    +

    +
    - - Generate Share Link - - Copy +
    +

    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    -
    @@ -106,7 +133,7 @@
    -
    #} +
    {% endblock page_content %} @@ -115,19 +142,23 @@ {{ super() }} -{# #} + {% endblock scripts %} From 68dc8de476446cc6f66c6ce26fb85d6be8f410ab Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Tue, 21 Feb 2023 16:23:10 +0100 Subject: [PATCH 008/177] Add function to dynamically add followers to CorpusFollowerList --- app/corpora/routes.py | 10 ++++++ app/models.py | 32 +++++++++---------- .../js/ResourceLists/CorpusFollowerList.js | 4 +-- app/static/js/ResourceLists/ResourceList.js | 3 +- 4 files changed, 30 insertions(+), 19 deletions(-) diff --git a/app/corpora/routes.py b/app/corpora/routes.py index 17d203f1..0b2c788e 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -31,6 +31,16 @@ from .forms import ( ) +@bp.route('/fake-add') +@login_required +def fake_add(): + pjentsch = User.query.filter_by(username='pjentsch').first() + alice = Corpus.query.filter_by(title='Alice in Wonderland').first() + pjentsch.follow_corpus(alice) + db.session.commit() + return '' + + @bp.route('//is_public/enable', methods=['POST']) @login_required def enable_corpus_is_public(corpus_id): diff --git a/app/models.py b/app/models.py index 4e40793b..e2706eb5 100644 --- a/app/models.py +++ b/app/models.py @@ -1489,22 +1489,22 @@ def ressource_after_insert_handler(mapper, connection, ressource): socketio.emit('PATCH', jsonpatch, room=room) -# @db.event.listens_for(CorpusFollowerAssociation, 'after_insert') -# def corpus_follower_association_after_insert_handler(mapper, connection, ressource): -# corpus_owner_hashid = ressource.corpus.user.hashid -# corpus_hashid = hashids.encode(ressource.corpus_id) -# follower_hashid = hashids.encode(ressource.follower_id) -# value = ressource.to_json_serializeable() -# # Send a PATCH to the corpus owner -# jsonpatch_path = f'/users/{corpus_owner_hashid}/corpora/{corpus_hashid}/corpus_follower_associations/{ressource.hashid}' -# jsonpatch = [{'op': 'add', 'path': jsonpatch_path, 'value': value}] -# room = f'/users/{corpus_owner_hashid}' -# socketio.emit('PATCH', jsonpatch, room=room) -# # Send a PATCH to the follower -# jsonpatch_path = f'/users/{follower_hashid}/corpus_follower_associations/{ressource.hashid}' -# jsonpatch = [{'op': 'add', 'path': jsonpatch_path, 'value': value}] -# room = f'/users/{follower_hashid}' -# socketio.emit('PATCH', jsonpatch, room=room) +@db.event.listens_for(CorpusFollowerAssociation, 'after_insert') +def corpus_follower_association_after_insert_handler(mapper, connection, ressource): + corpus_owner_hashid = ressource.corpus.user.hashid + corpus_hashid = hashids.encode(ressource.corpus_id) + follower_hashid = hashids.encode(ressource.follower_id) + value = ressource.to_json_serializeable() + # Send a PATCH to the corpus owner + jsonpatch_path = f'/users/{corpus_owner_hashid}/corpora/{corpus_hashid}/corpus_follower_associations/{ressource.hashid}' + jsonpatch = [{'op': 'add', 'path': jsonpatch_path, 'value': value}] + room = f'/users/{corpus_owner_hashid}' + socketio.emit('PATCH', jsonpatch, room=room) + # Send a PATCH to the follower + jsonpatch_path = f'/users/{follower_hashid}/corpus_follower_associations/{ressource.hashid}' + jsonpatch = [{'op': 'add', 'path': jsonpatch_path, 'value': value}] + room = f'/users/{follower_hashid}' + socketio.emit('PATCH', jsonpatch, room=room) @db.event.listens_for(Corpus, 'after_update') diff --git a/app/static/js/ResourceLists/CorpusFollowerList.js b/app/static/js/ResourceLists/CorpusFollowerList.js index 616f3793..be7e46d9 100644 --- a/app/static/js/ResourceLists/CorpusFollowerList.js +++ b/app/static/js/ResourceLists/CorpusFollowerList.js @@ -175,8 +175,8 @@ class CorpusFollowerList extends ResourceList { for (let operation of filteredPatch) { switch(operation.op) { case 'add': { - // let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/corpus_follower_associations/([A-Za-z0-9]*)$`); - // if (re.test(operation.path)) {this.add(operation.value);} + let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/corpus_follower_associations/([A-Za-z0-9]*)$`); + if (re.test(operation.path)) {this.add(operation.value);} break; } case 'remove': { diff --git a/app/static/js/ResourceLists/ResourceList.js b/app/static/js/ResourceLists/ResourceList.js index b7445553..3251ef2b 100644 --- a/app/static/js/ResourceLists/ResourceList.js +++ b/app/static/js/ResourceLists/ResourceList.js @@ -43,7 +43,8 @@ class ResourceList { } add(resources, callback) { - let values = resources.map((resource) => { + let tmp = Array.isArray(resources) ? resources : [resources]; + let values = tmp.map((resource) => { return this.mapResourceToValue(resource); }); this.listjs.add(values, (items) => { From 1be8a449fea660f7a3a5bd8b4d000f384e32724a Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Wed, 22 Feb 2023 09:35:19 +0100 Subject: [PATCH 009/177] cleanup in models file --- app/models.py | 59 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/app/models.py b/app/models.py index e2706eb5..79ee0ac7 100644 --- a/app/models.py +++ b/app/models.py @@ -205,6 +205,8 @@ class Role(HashidMixin, db.Model): if self.has_permission(x.value) ] } + if backrefs: + pass if relationships: json_serializeable['users'] = { x.hashid: x.to_json_serializeable(relationships=True) @@ -256,6 +258,27 @@ class Token(db.Model): self.access_expiration = datetime.utcnow() self.refresh_expiration = datetime.utcnow() + def to_json_serializeable(self, backrefs=False, relationships=False): + json_serializeable = { + 'id': self.hashid, + 'access_token': self.access_token, + 'access_expiration': ( + None if self.access_expiration is None + else f'{self.access_expiration.isoformat()}Z' + ), + 'refresh_token': self.refresh_token, + 'refresh_expiration': ( + None if self.refresh_expiration is None + else f'{self.refresh_expiration.isoformat()}Z' + ) + } + if backrefs: + json_serializeable['user'] = \ + self.user.to_json_serializeable(backrefs=True) + if relationships: + pass + return json_serializeable + @staticmethod def clean(): """Remove any tokens that have been expired for more than a day.""" @@ -288,6 +311,11 @@ class Avatar(HashidMixin, FileMixin, db.Model): 'id': self.hashid, **self.file_mixin_to_json_serializeable() } + if backrefs: + json_serializeable['user'] = \ + self.user.to_json_serializeable(backrefs=True) + if relationships: + pass return json_serializeable @@ -328,6 +356,13 @@ class CorpusFollowerAssociation(HashidMixin, db.Model): 'corpus': self.corpus.to_json_serializeable(), 'follower': self.follower.to_json_serializeable() } + if backrefs: + json_serializeable['corpus'] = \ + self.corpus.to_json_serializeable(backrefs=True) + json_serializeable['follower'] = \ + self.follower.to_json_serializeable(backrefs=True) + if relationships: + pass return json_serializeable @@ -620,7 +655,6 @@ class User(HashidMixin, UserMixin, db.Model): def is_following_corpus(self, corpus): return corpus in self.followed_corpora - def to_json_serializeable(self, backrefs=False, relationships=False, filter_by_privacy_settings=False): json_serializeable = { 'id': self.hashid, @@ -628,9 +662,9 @@ class User(HashidMixin, UserMixin, db.Model): 'email': self.email, 'last_seen': ( None if self.last_seen is None - else self.last_seen.strftime('%Y-%m-%d %H:%M') + else f'{self.last_seen.isoformat()}Z' ), - 'member_since': self.member_since.strftime('%Y-%m-%d'), + 'member_since': f'{self.member_since.isoformat()}Z', 'username': self.username, 'full_name': self.full_name, 'about_me': self.about_me, @@ -804,6 +838,8 @@ class TesseractOCRPipelineModel(FileMixin, HashidMixin, db.Model): if backrefs: json_serializeable['user'] = \ self.user.to_json_serializeable(backrefs=True) + if relationships: + pass return json_serializeable @@ -930,7 +966,10 @@ class SpaCyNLPPipelineModel(FileMixin, HashidMixin, db.Model): **self.file_mixin_to_json_serializeable() } if backrefs: - json_serializeable['user'] = self.user.to_json_serializeable(backrefs=True) + json_serializeable['user'] = \ + self.user.to_json_serializeable(backrefs=True) + if relationships: + pass return json_serializeable @@ -989,6 +1028,8 @@ class JobInput(FileMixin, HashidMixin, db.Model): if backrefs: json_serializeable['job'] = \ self.job.to_json_serializeable(backrefs=True) + if relationships: + pass return json_serializeable @@ -1053,6 +1094,8 @@ class JobResult(FileMixin, HashidMixin, db.Model): if backrefs: json_serializeable['job'] = \ self.job.to_json_serializeable(backrefs=True) + if relationships: + pass return json_serializeable @@ -1132,7 +1175,6 @@ class Job(HashidMixin, db.Model): raise e return job - def delete(self): ''' Delete the job and its inputs and results from the database. ''' if self.status not in [JobStatus.COMPLETED, JobStatus.FAILED]: # noqa @@ -1177,8 +1219,7 @@ class Job(HashidMixin, db.Model): 'service_args': self.service_args, 'service_version': self.service_version, 'status': self.status.name, - 'title': self.title, - 'url': self.url + 'title': self.title } if backrefs: json_serializeable['user'] = \ @@ -1264,9 +1305,9 @@ class CorpusFile(FileMixin, HashidMixin, db.Model): def to_json_serializeable(self, backrefs=False, relationships=False): json_serializeable = { 'id': self.hashid, - 'url': self.url, 'address': self.address, 'author': self.author, + 'description': self.description, 'booktitle': self.booktitle, 'chapter': self.chapter, 'editor': self.editor, @@ -1285,6 +1326,8 @@ class CorpusFile(FileMixin, HashidMixin, db.Model): if backrefs: json_serializeable['corpus'] = \ self.corpus.to_json_serializeable(backrefs=True) + if relationships: + pass return json_serializeable From 3ad942f17b5625a087586f6cb57bd37de1d83b27 Mon Sep 17 00:00:00 2001 From: Inga Kirschnick Date: Wed, 22 Feb 2023 16:00:04 +0100 Subject: [PATCH 010/177] Share link implementation for followers --- app/corpora/routes.py | 41 ++++++++++++------- .../js/RessourceDisplays/CorpusDisplay.js | 25 +++++++++++ app/static/js/Utils.js | 6 ++- app/templates/corpora/corpus.html.j2 | 35 +++------------- app/templates/corpora/public_corpus.html.j2 | 4 +- 5 files changed, 64 insertions(+), 47 deletions(-) diff --git a/app/corpora/routes.py b/app/corpora/routes.py index be3e7814..b6bedc24 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -57,17 +57,27 @@ def disable_corpus_is_public(corpus_id): @bp.route('//follow/') @login_required def follow_corpus(corpus_id, token): - try: + corpus = Corpus.query.get_or_404(corpus_id) + try: payload = jwt.decode( - token, - current_app.config['SECRET_KEY'], - algorithms=['HS256'], - issuer=current_app.config['SERVER_NAME'], - options={'require': ['iat', 'iss', 'sub']} - ) + token, + current_app.config['SECRET_KEY'], + algorithms=['HS256'], + issuer=current_app.config['SERVER_NAME'], + # options={'require': ['exp', 'iat', 'iss', 'sub']} + options={'require': ['exp', 'iat', 'iss']} + ) except jwt.PyJWTError: - return False - return redirect(url_for('.corpus', corpus_id=corpus_id)) + abort(403) + # permission = payload.get('sub') + expiration = payload.get('exp') + if expiration < int(datetime.utcnow().timestamp()): + abort(403) + if not current_user.is_following_corpus(corpus): + current_user.follow_corpus(corpus) + db.session.commit() + flash(f'You are following {corpus.title} now', category='corpus') + return redirect(url_for('corpora.corpus', corpus_id=corpus_id)) @bp.route('//followers//unfollow', methods=['POST']) @@ -170,6 +180,9 @@ def create_corpus(): def corpus(corpus_id): corpus = Corpus.query.get_or_404(corpus_id) exp_date = (datetime.utcnow() + timedelta(days=7)).strftime('%b %d, %Y') + print(corpus.user) + print(current_user) + print(current_user.is_following_corpus(corpus)) if corpus.user == current_user or current_user.is_administrator(): return render_template( 'corpora/corpus.html.j2', @@ -191,15 +204,15 @@ def corpus(corpus_id): @login_required def generate_corpus_share_link(corpus_id): data = request.get_json('data') - permission = data['permission'] - expiration = data['expiration'] - corpus = Corpus.query.get_or_404(corpus_id) + # permission = data['permission'] + exp_data = data['expiration'] + expiration = datetime.strptime(exp_data, '%b %d, %Y') now = datetime.utcnow() payload = { 'exp': expiration, 'iat': now, - 'iss': current_app.config['SERVER_NAME'], - 'sub': permission + 'iss': current_app.config['SERVER_NAME'] + # 'sub': permission } token = jwt.encode( payload, diff --git a/app/static/js/RessourceDisplays/CorpusDisplay.js b/app/static/js/RessourceDisplays/CorpusDisplay.js index fd342dd4..aab9e470 100644 --- a/app/static/js/RessourceDisplays/CorpusDisplay.js +++ b/app/static/js/RessourceDisplays/CorpusDisplay.js @@ -31,6 +31,7 @@ class CorpusDisplay extends RessourceDisplay { this.setStatus(corpus.status); this.setTitle(corpus.title); this.setNumTokens(corpus.num_tokens); + this.setShareLink(); } onPatch(patch) { @@ -117,4 +118,28 @@ class CorpusDisplay extends RessourceDisplay { new Date(creationDate).toLocaleString("en-US") ); } + + setShareLink() { + let generateShareLinkButton = this.displayElement.querySelector('#generate-share-link-button'); + let copyShareLinkButton = this.displayElement.querySelector('#copy-share-link-button'); + let shareLinkInput = this.displayElement.querySelector('#share-link-input'); + // let permissionSelect = this.displayElement.querySelector('#permission-select'); + let expirationDate = this.displayElement.querySelector('#expiration'); + + + generateShareLinkButton.addEventListener('click', () => { + // Utils.generateCorpusShareLinkRequest(`${this.corpusId}`, permissionSelect.value, expirationDate.value) + Utils.generateCorpusShareLinkRequest(`${this.corpusId}`, expirationDate.value) + .then((shareLink) => { + shareLinkInput.parentElement.classList.remove('hide'); + shareLinkInput.value = shareLink; + }); + }); + + copyShareLinkButton.addEventListener('click', () => { + shareLinkInput.select(); + navigator.clipboard.writeText(shareLinkInput.value); + app.flash(`Copied!`, 'success'); + }); + } } diff --git a/app/static/js/Utils.js b/app/static/js/Utils.js index 80340d6c..354f0f63 100644 --- a/app/static/js/Utils.js +++ b/app/static/js/Utils.js @@ -778,9 +778,11 @@ class Utils { }); } - static generateCorpusShareLinkRequest(corpusId, permission, expiration) { + // static generateCorpusShareLinkRequest(corpusId, permission, expiration) { + static generateCorpusShareLinkRequest(corpusId, expiration) { return new Promise((resolve, reject) => { - const data = {permission: permission, expiration: expiration}; + // const data = {permission: permission, expiration: expiration}; + const data = {expiration: expiration}; fetch(`/corpora/${corpusId}/generate-corpus-share-link`, {method: 'POST', headers: {Accept: 'text/plain'}, body: JSON.stringify(data)}) .then( (response) => { diff --git a/app/templates/corpora/corpus.html.j2 b/app/templates/corpora/corpus.html.j2 index 0e982d70..8a4976e2 100644 --- a/app/templates/corpora/corpus.html.j2 +++ b/app/templates/corpora/corpus.html.j2 @@ -96,7 +96,7 @@

    -
    + {#
    + Copy +
    @@ -142,30 +144,5 @@ {{ super() }} {% endblock scripts %} diff --git a/app/templates/corpora/public_corpus.html.j2 b/app/templates/corpora/public_corpus.html.j2 index 94b3524c..63744441 100644 --- a/app/templates/corpora/public_corpus.html.j2 +++ b/app/templates/corpora/public_corpus.html.j2 @@ -61,13 +61,13 @@ let unfollowRequestElement = document.querySelector('.action-button[data-action="unfollow-request"]'); unfollowRequestElement.addEventListener('click', () => { return new Promise((resolve, reject) => { - fetch('{{ url_for("corpora.unfollow_corpus", corpus_id=corpus.id) }}', {method: 'POST', headers: {Accept: 'application/json'}}) + fetch('{{ url_for("corpora.current_user_unfollow_corpus", corpus_id=corpus.id) }}', {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);} resolve(response); - window.location.href = '{{ url_for("corpora.corpus", corpus_id=corpus.id) }}'; + window.location.href = '{{ url_for("main.dashboard") }}'; }, (response) => { app.flash('Something went wrong', 'error'); From a459d6607a4f41db0fe29a6617f60b148bdce36a Mon Sep 17 00:00:00 2001 From: Inga Kirschnick Date: Thu, 23 Feb 2023 09:50:09 +0100 Subject: [PATCH 011/177] Update Share Link --- app/corpora/routes.py | 5 +---- app/static/js/RessourceDisplays/CorpusDisplay.js | 3 ++- app/templates/corpora/corpus.html.j2 | 14 +++++++++++--- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/app/corpora/routes.py b/app/corpora/routes.py index ab07ca92..9341938a 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -78,11 +78,8 @@ def follow_corpus(corpus_id, token): options={'require': ['exp', 'iat', 'iss']} ) except jwt.PyJWTError: - abort(403) + abort(410) # permission = payload.get('sub') - expiration = payload.get('exp') - if expiration < int(datetime.utcnow().timestamp()): - abort(403) if not current_user.is_following_corpus(corpus): current_user.follow_corpus(corpus) db.session.commit() diff --git a/app/static/js/RessourceDisplays/CorpusDisplay.js b/app/static/js/RessourceDisplays/CorpusDisplay.js index aab9e470..d42fa20c 100644 --- a/app/static/js/RessourceDisplays/CorpusDisplay.js +++ b/app/static/js/RessourceDisplays/CorpusDisplay.js @@ -123,6 +123,7 @@ class CorpusDisplay extends RessourceDisplay { let generateShareLinkButton = this.displayElement.querySelector('#generate-share-link-button'); let copyShareLinkButton = this.displayElement.querySelector('#copy-share-link-button'); let shareLinkInput = this.displayElement.querySelector('#share-link-input'); + let shareLinkContainer = this.displayElement.querySelector('#share-link-container'); // let permissionSelect = this.displayElement.querySelector('#permission-select'); let expirationDate = this.displayElement.querySelector('#expiration'); @@ -131,7 +132,7 @@ class CorpusDisplay extends RessourceDisplay { // Utils.generateCorpusShareLinkRequest(`${this.corpusId}`, permissionSelect.value, expirationDate.value) Utils.generateCorpusShareLinkRequest(`${this.corpusId}`, expirationDate.value) .then((shareLink) => { - shareLinkInput.parentElement.classList.remove('hide'); + shareLinkContainer.classList.remove('hide'); shareLinkInput.value = shareLink; }); }); diff --git a/app/templates/corpora/corpus.html.j2 b/app/templates/corpora/corpus.html.j2 index 8a4976e2..196faef2 100644 --- a/app/templates/corpora/corpus.html.j2 +++ b/app/templates/corpora/corpus.html.j2 @@ -119,9 +119,17 @@
    Generate Share Link -
    - - Copy +
    +
    From 38d09a34904f1b323ecb4f5c9ded23674f448ad4 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Thu, 23 Feb 2023 13:05:04 +0100 Subject: [PATCH 012/177] Integrate CorpusFollowerRoles --- app/cli.py | 17 +- app/corpora/routes.py | 29 +-- app/models.py | 191 ++++++++++++++---- .../js/ResourceLists/CorpusFollowerList.js | 84 ++++---- app/templates/_sidenav.html.j2 | 6 +- migrations/versions/1f77ce4346c6_.py | 72 +++++++ nopaque.py | 11 +- 7 files changed, 284 insertions(+), 126 deletions(-) create mode 100644 migrations/versions/1f77ce4346c6_.py diff --git a/app/cli.py b/app/cli.py index 826aa790..e59a080d 100644 --- a/app/cli.py +++ b/app/cli.py @@ -3,10 +3,11 @@ from flask_migrate import upgrade import click import os from app.models import ( + CorpusFollowerRole, Role, - User, + SpaCyNLPPipelineModel, TesseractOCRPipelineModel, - SpaCyNLPPipelineModel + User ) @@ -30,19 +31,23 @@ def register(app): def deploy(): ''' Run deployment tasks. ''' # Make default directories + print('Make default directories') _make_default_dirs() # migrate database to latest revision + print('Migrate database to latest revision') upgrade() # Insert/Update default database values - current_app.logger.info('Insert/Update default roles') + print('Insert/Update default Roles') Role.insert_defaults() - current_app.logger.info('Insert/Update default users') + print('Insert/Update default Users') User.insert_defaults() - current_app.logger.info('Insert/Update default SpaCyNLPPipelineModels') + print('Insert/Update default CorpusFollowerRoles') + CorpusFollowerRole.insert_defaults() + print('Insert/Update default SpaCyNLPPipelineModels') SpaCyNLPPipelineModel.insert_defaults() - current_app.logger.info('Insert/Update default TesseractOCRPipelineModels') + print('Insert/Update default TesseractOCRPipelineModels') TesseractOCRPipelineModel.insert_defaults() @app.cli.group() diff --git a/app/corpora/routes.py b/app/corpora/routes.py index 2d59426b..ecfc1e84 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -21,6 +21,7 @@ from app.models import ( CorpusFile, CorpusFollowerAssociation, CorpusFollowerPermission, + CorpusFollowerRole, CorpusStatus, User ) @@ -107,30 +108,16 @@ def current_user_unfollow_corpus(corpus_id): return '', 204 -@bp.route('//followers//permissions//add', methods=['POST']) -def add_permission(corpus_id, follower_id, permission_name): - try: - permission = CorpusFollowerPermission[permission_name] - except KeyError: - abort(409) # f'Permission "{permission_name}" does not exist' +@bp.route('//followers//role', methods=['POST']) +def add_permission(corpus_id, follower_id): corpus_follower_association = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=follower_id).first_or_404() if not (corpus_follower_association.corpus.user == current_user or current_user.is_administrator()): abort(403) - corpus_follower_association.add_permission(permission) - db.session.commit() - return '', 204 - - -@bp.route('//followers//permissions//remove', methods=['POST']) -def remove_permission(corpus_id, follower_id, permission_name): - try: - permission = CorpusFollowerPermission[permission_name] - except KeyError: - return make_response(f'Permission "{permission_name}" does not exist', 409) - corpus_follower_association = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=follower_id).first_or_404() - if not (corpus_follower_association.corpus.user == current_user or current_user.is_administrator()): - abort(403) - corpus_follower_association.remove_permission(permission) + role_name = request.json.get('role') + if role_name is None: + abort(400) + corpus_follower_role = CorpusFollowerRole.query.filter_by(name=role_name).first_or_404() + corpus_follower_association.role = corpus_follower_role db.session.commit() return '', 204 diff --git a/app/models.py b/app/models.py index 79ee0ac7..2444589c 100644 --- a/app/models.py +++ b/app/models.py @@ -6,6 +6,7 @@ from flask_login import UserMixin from sqlalchemy.ext.associationproxy import association_proxy from time import sleep from tqdm import tqdm +from typing import Union from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.utils import secure_filename import json @@ -57,6 +58,15 @@ class Permission(IntEnum): CONTRIBUTE = 2 USE_API = 4 + @staticmethod + def get(permission: Union['Permission', int, str]) -> 'Permission': + if isinstance(permission, Permission): + return permission + if isinstance(permission, int): + return Permission(permission) + if isinstance(permission, str): + return Permission[permission] + raise TypeError('permission must be Permission, int, or str') class UserSettingJobStatusMailNotificationLevel(IntEnum): NONE = 1 @@ -72,8 +82,22 @@ class ProfilePrivacySettings(IntEnum): class CorpusFollowerPermission(IntEnum): VIEW = 1 - CONTRIBUTE = 2 - ADMINISTRATE = 4 + ADD_CORPUS_FILE = 2 + UPDATE_CORPUS_FILE = 4 + REMOVE_CORPUS_FILE = 8 + GENERATE_SHARE_LINK = 16 + REMOVE_FOLLOWER = 32 + UPDATE_FOLLOWER = 64 + + @staticmethod + def get(permission: Union['CorpusFollowerPermission', int, str]) -> 'CorpusFollowerPermission': + if isinstance(permission, CorpusFollowerPermission): + return permission + if isinstance(permission, int): + return CorpusFollowerPermission(permission) + if isinstance(permission, str): + return CorpusFollowerPermission[permission] + raise TypeError('permission must be CorpusFollowerPermission, int, or str') # endregion enums @@ -181,16 +205,19 @@ class Role(HashidMixin, db.Model): def __repr__(self): return f'' - def add_permission(self, permission): - if not self.has_permission(permission): - self.permissions += permission - - def has_permission(self, permission): - return self.permissions & permission == permission - - def remove_permission(self, permission): - if self.has_permission(permission): - self.permissions -= permission + def has_permission(self, permission: Union[Permission, int, str]): + perm = Permission.get(permission) + return self.permissions & perm.value == perm.value + + def add_permission(self, permission: Union[Permission, int, str]): + perm = Permission.get(permission) + if not self.has_permission(perm): + self.permissions += perm.value + + def remove_permission(self, permission: Union[Permission, int, str]): + perm = Permission.get(permission) + if self.has_permission(perm): + self.permissions -= perm.value def reset_permissions(self): self.permissions = 0 @@ -319,6 +346,96 @@ class Avatar(HashidMixin, FileMixin, db.Model): return json_serializeable +class CorpusFollowerRole(HashidMixin, db.Model): + __tablename__ = 'corpus_follower_roles' + # Primary key + id = db.Column(db.Integer, primary_key=True) + # Fields + name = db.Column(db.String(64), unique=True) + default = db.Column(db.Boolean, default=False, index=True) + permissions = db.Column(db.Integer, default=0) + # Relationships + corpus_follower_associations = db.relationship( + 'CorpusFollowerAssociation', + back_populates='role', + lazy='dynamic' + ) + + def __repr__(self): + return f'' + + def has_permission(self, permission: Union[CorpusFollowerPermission, int, str]): + perm = CorpusFollowerPermission.get(permission) + return self.permissions & perm.value == perm.value + + def add_permission(self, permission: Union[CorpusFollowerPermission, int, str]): + perm = CorpusFollowerPermission.get(permission) + if not self.has_permission(perm): + self.permissions += perm.value + + def remove_permission(self, permission: Union[CorpusFollowerPermission, int, str]): + perm = CorpusFollowerPermission.get(permission) + if self.has_permission(perm): + self.permissions -= perm.value + + def reset_permissions(self): + self.permissions = 0 + + def to_json_serializeable(self, backrefs=False, relationships=False): + json_serializeable = { + 'id': self.hashid, + 'default': self.default, + 'name': self.name, + 'permissions': [ + x.name + for x in CorpusFollowerPermission + if self.has_permission(x) + ] + } + if backrefs: + pass + if relationships: + json_serializeable['corpus_follower_association'] = { + x.hashid: x.to_json_serializeable(relationships=True) + for x in self.corpus_follower_association + } + return json_serializeable + + @staticmethod + def insert_defaults(): + roles = { + 'Viewer': [ + CorpusFollowerPermission.VIEW + ], + 'Contributor': [ + CorpusFollowerPermission.VIEW, + CorpusFollowerPermission.ADD_CORPUS_FILE, + CorpusFollowerPermission.UPDATE_CORPUS_FILE, + CorpusFollowerPermission.REMOVE_CORPUS_FILE + ], + 'Administrator': [ + CorpusFollowerPermission.VIEW, + CorpusFollowerPermission.ADD_CORPUS_FILE, + CorpusFollowerPermission.UPDATE_CORPUS_FILE, + CorpusFollowerPermission.REMOVE_CORPUS_FILE, + CorpusFollowerPermission.GENERATE_SHARE_LINK, + CorpusFollowerPermission.REMOVE_FOLLOWER, + CorpusFollowerPermission.UPDATE_FOLLOWER + ] + } + default_role_name = 'Viewer' + for role_name, permissions in roles.items(): + role = CorpusFollowerRole.query.filter_by(name=role_name).first() + if role is None: + role = CorpusFollowerRole(name=role_name) + role.reset_permissions() + for permission in permissions: + role.add_permission(permission) + role.default = role.name == default_role_name + db.session.add(role) + db.session.commit() + + class CorpusFollowerAssociation(HashidMixin, db.Model): __tablename__ = 'corpus_follower_associations' # Primary key @@ -326,41 +443,38 @@ class CorpusFollowerAssociation(HashidMixin, db.Model): # Foreign keys corpus_id = db.Column(db.Integer, db.ForeignKey('corpora.id')) follower_id = db.Column(db.Integer, db.ForeignKey('users.id')) - # Fields - permissions = db.Column(db.Integer, default=0, nullable=False) + role_id = db.Column(db.Integer, db.ForeignKey('corpus_follower_roles.id')) # Relationships - corpus = db.relationship('Corpus', back_populates='corpus_follower_associations') - follower = db.relationship('User', back_populates='corpus_follower_associations') + corpus = db.relationship( + 'Corpus', + back_populates='corpus_follower_associations' + ) + follower = db.relationship( + 'User', + back_populates='corpus_follower_associations' + ) + role = db.relationship( + 'CorpusFollowerRole', + back_populates='corpus_follower_associations' + ) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + if self.role is None: + self.role = CorpusFollowerRole.query.filter_by(default=True).first() def __repr__(self): return f'' - def has_permission(self, permission: CorpusFollowerPermission): - return self.permissions & permission.value == permission.value - - def add_permission(self, permission: CorpusFollowerPermission): - if not self.has_permission(permission): - self.permissions += permission.value - - def remove_permission(self, permission: CorpusFollowerPermission): - if self.has_permission(permission): - self.permissions -= permission.value - def to_json_serializeable(self, backrefs=False, relationships=False): json_serializeable = { 'id': self.hashid, - 'permissions': [ - x.name for x in CorpusFollowerPermission - if self.has_permission(x) - ], 'corpus': self.corpus.to_json_serializeable(), - 'follower': self.follower.to_json_serializeable() + 'follower': self.follower.to_json_serializeable(), + 'role': self.role.to_json_serializeable() } if backrefs: - json_serializeable['corpus'] = \ - self.corpus.to_json_serializeable(backrefs=True) - json_serializeable['follower'] = \ - self.follower.to_json_serializeable(backrefs=True) + pass if relationships: pass return json_serializeable @@ -559,7 +673,6 @@ class User(HashidMixin, UserMixin, db.Model): issuer=current_app.config['SERVER_NAME'], options={'require': ['exp', 'iat', 'iss', 'purpose', 'sub']} ) - current_app.logger.warning(payload) except jwt.PyJWTError: return False if payload.get('purpose') != 'user.confirm': @@ -687,7 +800,7 @@ class User(HashidMixin, UserMixin, db.Model): self.role.to_json_serializeable(backrefs=True) if relationships: json_serializeable['corpus_follower_associations'] = { - x.hashid: x.to_json_serializeable(relationships=True) + x.hashid: x.to_json_serializeable() for x in self.corpus_follower_associations } json_serializeable['corpora'] = { @@ -1469,7 +1582,7 @@ class Corpus(HashidMixin, db.Model): self.user.to_json_serializeable(backrefs=True) if relationships: json_serializeable['corpus_follower_associations'] = { - x.hashid: x.to_json_serializeable(relationships=True) + x.hashid: x.to_json_serializeable() for x in self.corpus_follower_associations } json_serializeable['files'] = { diff --git a/app/static/js/ResourceLists/CorpusFollowerList.js b/app/static/js/ResourceLists/CorpusFollowerList.js index be7e46d9..cc94c568 100644 --- a/app/static/js/ResourceLists/CorpusFollowerList.js +++ b/app/static/js/ResourceLists/CorpusFollowerList.js @@ -7,6 +7,9 @@ class CorpusFollowerList extends ResourceList { constructor(listContainerElement, options = {}) { super(listContainerElement, options); + this.listjs.on('updated', () => { + M.FormSelect.init(this.listjs.list.querySelectorAll('.list-item select')); + }); this.listjs.list.addEventListener('change', (event) => {this.onChange(event)}); this.listjs.list.addEventListener('click', (event) => {this.onClick(event)}); this.isInitialized = false; @@ -28,30 +31,21 @@ class CorpusFollowerList extends ResourceList { return (values) => { return ` - user-image - -
    + follower-avatar + - - - +
    - - - -
    - - - + + + +
    + +
    delete @@ -66,10 +60,10 @@ class CorpusFollowerList extends ResourceList { return [ {data: ['id']}, {data: ['follower-id']}, - {name: 'avatar', attr: 'src'}, - 'username', - 'about-me', - 'full-name' + {name: 'follower-avatar', attr: 'src'}, + 'follower-username', + 'follower-about-me', + 'follower-full-name' ]; } @@ -90,7 +84,7 @@ class CorpusFollowerList extends ResourceList { Username User details - Permissions + Role @@ -104,13 +98,11 @@ class CorpusFollowerList extends ResourceList { return { 'id': corpusFollowerAssociation.id, 'follower-id': corpusFollowerAssociation.follower.id, - 'avatar': corpusFollowerAssociation.follower.avatar ? `/users/${corpusFollowerAssociation.follower.id}/avatar` : '/static/images/user_avatar.png', - 'username': corpusFollowerAssociation.follower.username, - 'full-name': corpusFollowerAssociation.follower.full_name ? corpusFollowerAssociation.follower.full_name : '', - 'about-me': corpusFollowerAssociation.follower.about_me ? corpusFollowerAssociation.follower.about_me : '', - 'permission-can-VIEW': corpusFollowerAssociation.permissions.includes('VIEW'), - 'permission-can-CONTRIBUTE': corpusFollowerAssociation.permissions.includes('CONTRIBUTE'), - 'permission-can-ADMINISTRATE': corpusFollowerAssociation.permissions.includes('ADMINISTRATE') + 'follower-avatar': corpusFollowerAssociation.follower.avatar ? `/users/${corpusFollowerAssociation.follower.id}/avatar` : '/static/images/user_avatar.png', + 'follower-username': corpusFollowerAssociation.follower.username, + 'follower-full-name': corpusFollowerAssociation.follower.full_name ? corpusFollowerAssociation.follower.full_name : '', + 'follower-about-me': corpusFollowerAssociation.follower.about_me ? corpusFollowerAssociation.follower.about_me : '', + 'role-name': corpusFollowerAssociation.role.name }; } @@ -119,7 +111,7 @@ class CorpusFollowerList extends ResourceList { } onChange(event) { - if (event.target.tagName !== 'INPUT') {return;} + console.log(event.target.tagName); let listItemElement = event.target.closest('.list-item[data-id]'); if (listItemElement === null) {return;} let itemId = listItemElement.dataset.id; @@ -127,16 +119,10 @@ class CorpusFollowerList extends ResourceList { if (listActionElement === null) {return;} let listAction = listActionElement.dataset.listAction; switch (listAction) { - case 'toggle-permission': { + case 'update-role': { let followerId = listItemElement.dataset.followerId; - let permission = listActionElement.dataset.permission; - if (event.target.checked) { - Utils.addCorpusFollowerPermissionRequest(this.corpusId, followerId, permission) - .catch((error) => {event.target.checked = !event.target.checked;}); - } else { - Utils.removeCorpusFollowerPermissionRequest(this.corpusId, followerId, permission) - .catch((error) => {event.target.checked = !event.target.checked;}); - } + let roleName = event.target.value; + Utils.updateCorpusFollowerRole(this.corpusId, followerId, roleName); break; } default: { @@ -188,11 +174,11 @@ class CorpusFollowerList extends ResourceList { break; } case 'replace': { - // let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/corpus_follower_associations/([A-Za-z0-9]*)/(service|status|description|title)$`); - // if (re.test(operation.path)) { - // let [match, jobId, valueName] = operation.path.match(re); - // this.replace(jobId, valueName, operation.value); - // } + let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/corpus_follower_associations/([A-Za-z0-9]*)/role$`); + if (re.test(operation.path)) { + let [match, jobId, valueName] = operation.path.match(re); + this.replace(jobId, valueName, operation.value); + } break; } default: { diff --git a/app/templates/_sidenav.html.j2 b/app/templates/_sidenav.html.j2 index 676b7069..7fa34623 100644 --- a/app/templates/_sidenav.html.j2 +++ b/app/templates/_sidenav.html.j2 @@ -36,13 +36,13 @@
  • settingsGeneral Settings
  • contact_pageProfile settings
  • Log out
  • - {% if current_user.can(Permission.ADMINISTRATE) or current_user.can(Permission.USE_API) %} + {% if current_user.can('ADMINISTRATE') or current_user.can('USE_API') %}
  • {% endif %} - {% if current_user.can(Permission.ADMINISTRATE) %} + {% if current_user.can('ADMINISTRATE') %}
  • admin_panel_settingsAdministration
  • {% endif %} - {% if current_user.can(Permission.USE_API) %} + {% if current_user.can('USE_API') %}
  • apiAPI
  • {% endif %} diff --git a/migrations/versions/1f77ce4346c6_.py b/migrations/versions/1f77ce4346c6_.py new file mode 100644 index 00000000..1d7e2160 --- /dev/null +++ b/migrations/versions/1f77ce4346c6_.py @@ -0,0 +1,72 @@ +"""empty message + +Revision ID: 1f77ce4346c6 +Revises: 03c7211f089d +Create Date: 2023-02-22 12:56:30.176665 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '1f77ce4346c6' +down_revision = '03c7211f089d' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'corpus_follower_roles', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=64), nullable=True), + sa.Column('default', sa.Boolean(), nullable=True), + sa.Column('permissions', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_index( + op.f('ix_corpus_follower_roles_default'), + 'corpus_follower_roles', + ['default'], + unique=False + ) + + op.add_column( + 'corpus_follower_associations', + sa.Column('role_id', sa.Integer(), nullable=True) + ) + op.create_foreign_key( + 'fk_corpus_follower_associations_role_id_corpus_follower_roles', + 'corpus_follower_associations', + 'corpus_follower_roles', + ['role_id'], + ['id'] + ) + op.drop_column('corpus_follower_associations', 'permissions') + + +def downgrade(): + op.add_column( + 'corpus_follower_associations', + sa.Column('permissions', sa.Integer(), nullable=True) + ) + op.execute('UPDATE corpus_follower_associations SET permissions = 0;') + op.alter_column( + 'corpus_follower_associations', + 'permissions', + nullable=False + ) + op.drop_constraint( + 'fk_corpus_follower_associations_role_id_corpus_follower_roles', + 'corpus_follower_associations', + type_='foreignkey' + ) + op.drop_column('corpus_follower_associations', 'role_id') + + op.drop_index( + op.f('ix_corpus_follower_roles_default'), + table_name='corpus_follower_roles' + ) + op.drop_table('corpus_follower_roles') diff --git a/nopaque.py b/nopaque.py index 602954cc..bbcf799d 100644 --- a/nopaque.py +++ b/nopaque.py @@ -9,6 +9,7 @@ from app.models import ( Corpus, CorpusFile, CorpusFollowerAssociation, + CorpusFollowerRole, Job, JobInput, JobResult, @@ -26,25 +27,19 @@ app: Flask = create_app() cli.register(app) -@app.context_processor -def make_context() -> Dict[str, Any]: - ''' Adds variables to the template context. ''' - return {'Permission': Permission} - - @app.shell_context_processor def make_shell_context() -> Dict[str, Any]: ''' Adds variables to the shell context. ''' return { + 'db': db, 'Avatar': Avatar, 'Corpus': Corpus, 'CorpusFile': CorpusFile, 'CorpusFollowerAssociation': CorpusFollowerAssociation, - 'db': db, + 'CorpusFollowerRole': CorpusFollowerRole, 'Job': Job, 'JobInput': JobInput, 'JobResult': JobResult, - 'Permission': Permission, 'Role': Role, 'TesseractOCRPipelineModel': TesseractOCRPipelineModel, 'SpaCyNLPPipelineModel': SpaCyNLPPipelineModel, From 132875bb34868850428c1832a7bcd0b0315aeda2 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Thu, 23 Feb 2023 13:06:06 +0100 Subject: [PATCH 013/177] Remove unused import --- nopaque.py | 1 - 1 file changed, 1 deletion(-) diff --git a/nopaque.py b/nopaque.py index bbcf799d..b369556a 100644 --- a/nopaque.py +++ b/nopaque.py @@ -13,7 +13,6 @@ from app.models import ( Job, JobInput, JobResult, - Permission, Role, TesseractOCRPipelineModel, SpaCyNLPPipelineModel, From 1d85e96d3afbcbcaa8c33667fd96011523ee0bf4 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Thu, 23 Feb 2023 15:18:53 +0100 Subject: [PATCH 014/177] Let the Corpus owner change Roles of followers --- app/models.py | 39 ++++++++++++++----- .../js/ResourceLists/CorpusFollowerList.js | 7 ++-- app/static/js/Utils.js | 27 ++----------- 3 files changed, 37 insertions(+), 36 deletions(-) diff --git a/app/models.py b/app/models.py index 2444589c..01e7c09a 100644 --- a/app/models.py +++ b/app/models.py @@ -37,6 +37,16 @@ class CorpusStatus(IntEnum): RUNNING_ANALYSIS_SESSION = 8 CANCELING_ANALYSIS_SESSION = 9 + @staticmethod + def get(corpus_status: Union['CorpusStatus', int, str]) -> 'CorpusStatus': + if isinstance(corpus_status, CorpusStatus): + return corpus_status + if isinstance(corpus_status, int): + return CorpusStatus(corpus_status) + if isinstance(corpus_status, str): + return CorpusStatus[corpus_status] + raise TypeError('corpus_status must be CorpusStatus, int, or str') + class JobStatus(IntEnum): INITIALIZING = 1 @@ -48,6 +58,16 @@ class JobStatus(IntEnum): COMPLETED = 7 FAILED = 8 + @staticmethod + def get(job_status: Union['JobStatus', int, str]) -> 'JobStatus': + if isinstance(job_status, JobStatus): + return job_status + if isinstance(job_status, int): + return JobStatus(job_status) + if isinstance(job_status, str): + return JobStatus[job_status] + raise TypeError('job_status must be JobStatus, int, or str') + class Permission(IntEnum): ''' @@ -68,6 +88,7 @@ class Permission(IntEnum): return Permission[permission] raise TypeError('permission must be Permission, int, or str') + class UserSettingJobStatusMailNotificationLevel(IntEnum): NONE = 1 END = 2 @@ -90,14 +111,14 @@ class CorpusFollowerPermission(IntEnum): UPDATE_FOLLOWER = 64 @staticmethod - def get(permission: Union['CorpusFollowerPermission', int, str]) -> 'CorpusFollowerPermission': - if isinstance(permission, CorpusFollowerPermission): - return permission - if isinstance(permission, int): - return CorpusFollowerPermission(permission) - if isinstance(permission, str): - return CorpusFollowerPermission[permission] - raise TypeError('permission must be CorpusFollowerPermission, int, or str') + def get(corpus_follower_permission: Union['CorpusFollowerPermission', int, str]) -> 'CorpusFollowerPermission': + if isinstance(corpus_follower_permission, CorpusFollowerPermission): + return corpus_follower_permission + if isinstance(corpus_follower_permission, int): + return CorpusFollowerPermission(corpus_follower_permission) + if isinstance(corpus_follower_permission, str): + return CorpusFollowerPermission[corpus_follower_permission] + raise TypeError('corpus_follower_permission must be CorpusFollowerPermission, int, or str') # endregion enums @@ -555,7 +576,7 @@ class User(HashidMixin, UserMixin, db.Model): cascade='all, delete-orphan', lazy='dynamic' ) - + def __init__(self, **kwargs): super().__init__(**kwargs) if self.role is not None: diff --git a/app/static/js/ResourceLists/CorpusFollowerList.js b/app/static/js/ResourceLists/CorpusFollowerList.js index cc94c568..b0b621d2 100644 --- a/app/static/js/ResourceLists/CorpusFollowerList.js +++ b/app/static/js/ResourceLists/CorpusFollowerList.js @@ -41,9 +41,9 @@ class CorpusFollowerList extends ResourceList {
    @@ -176,6 +176,7 @@ class CorpusFollowerList extends ResourceList { case 'replace': { let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/corpus_follower_associations/([A-Za-z0-9]*)/role$`); if (re.test(operation.path)) { + console.log('role updated'); let [match, jobId, valueName] = operation.path.match(re); this.replace(jobId, valueName, operation.value); } diff --git a/app/static/js/Utils.js b/app/static/js/Utils.js index 354f0f63..f8dbf2d0 100644 --- a/app/static/js/Utils.js +++ b/app/static/js/Utils.js @@ -69,13 +69,13 @@ class Utils { return Utils.mergeObjectsDeep(mergedObject, ...objects.slice(2)); } - static addCorpusFollowerPermissionRequest(corpusId, followerId, permission) { + static updateCorpusFollowerRole(corpusId, followerId, roleName) { return new Promise((resolve, reject) => { - fetch(`/corpora/${corpusId}/followers/${followerId}/permissions/${permission}/add`, {method: 'POST', headers: {Accept: 'application/json'}}) + fetch(`/corpora/${corpusId}/followers/${followerId}/role`, {method: 'POST', headers: {Accept: 'application/json', 'Content-Type': 'application/json'}, body: JSON.stringify({role: roleName})}) .then( (response) => { if (response.ok) { - app.flash(`Permission added`, 'corpus'); + app.flash('Role updated', 'corpus'); resolve(response); return; } else { @@ -91,27 +91,6 @@ class Utils { }); } - static removeCorpusFollowerPermissionRequest(corpusId, followerId, permission) { - return new Promise((resolve, reject) => { - fetch(`/corpora/${corpusId}/followers/${followerId}/permissions/${permission}/remove`, {method: 'POST', headers: {Accept: 'application/json'}}) - .then( - (response) => { - if (response.ok) { - app.flash(`Permission removed`, 'corpus'); - resolve(response); - } else { - app.flash(`${response.statusText}`, 'error'); - reject(response); - } - }, - (response) => { - app.flash('Something went wrong', 'error'); - reject(response); - } - ); - }); - } - static enableCorpusIsPublicRequest(userId, corpusId) { return new Promise((resolve, reject) => { let corpus; From 0609e2cd7239c74e1ffb9a7c2c15294e6797cb65 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Fri, 24 Feb 2023 09:27:20 +0100 Subject: [PATCH 015/177] Fix follow corpus mechanics --- app/models.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/app/models.py b/app/models.py index 01e7c09a..ed36a8ff 100644 --- a/app/models.py +++ b/app/models.py @@ -378,8 +378,7 @@ class CorpusFollowerRole(HashidMixin, db.Model): # Relationships corpus_follower_associations = db.relationship( 'CorpusFollowerAssociation', - back_populates='role', - lazy='dynamic' + back_populates='role' ) def __repr__(self): @@ -481,11 +480,9 @@ class CorpusFollowerAssociation(HashidMixin, db.Model): def __init__(self, **kwargs): super().__init__(**kwargs) - if self.role is None: - self.role = CorpusFollowerRole.query.filter_by(default=True).first() def __repr__(self): - return f'' + return f'' def to_json_serializeable(self, backrefs=False, relationships=False): json_serializeable = { @@ -545,8 +542,7 @@ class User(HashidMixin, UserMixin, db.Model): ) followed_corpora = association_proxy( 'corpus_follower_associations', - 'corpus', - creator=lambda c: CorpusFollowerAssociation(corpus=c) + 'corpus' ) jobs = db.relationship( 'Job', @@ -778,9 +774,11 @@ class User(HashidMixin, UserMixin, db.Model): self.profile_privacy_settings = 0 #endregion Profile Privacy settings - def follow_corpus(self, corpus): - if not self.is_following_corpus(corpus): - self.followed_corpora.append(corpus) + def follow_corpus(self, corpus, role=None): + if role is None: + r = CorpusFollowerRole.query.filter_by(default=True).first() + cfa = CorpusFollowerAssociation(corpus=corpus, role=r, follower=self) + db.session.add(cfa) def unfollow_corpus(self, corpus): if self.is_following_corpus(corpus): @@ -1499,8 +1497,7 @@ class Corpus(HashidMixin, db.Model): ) followers = association_proxy( 'corpus_follower_associations', - 'follower', - creator=lambda u: CorpusFollowerAssociation(followers=u) + 'follower' ) user = db.relationship('User', back_populates='corpora') # "static" attributes From b27a1051af79e0362ab20c6663e7e9950c20273b Mon Sep 17 00:00:00 2001 From: Inga Kirschnick Date: Fri, 24 Feb 2023 09:30:29 +0100 Subject: [PATCH 016/177] import share link token generation to models.py --- app/corpora/routes.py | 32 ++++---------------------------- app/models.py | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 29 deletions(-) diff --git a/app/corpora/routes.py b/app/corpora/routes.py index 1c669f61..fd718145 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -69,19 +69,9 @@ def disable_corpus_is_public(corpus_id): @login_required def follow_corpus(corpus_id, token): corpus = Corpus.query.get_or_404(corpus_id) - try: - payload = jwt.decode( - token, - current_app.config['SECRET_KEY'], - algorithms=['HS256'], - issuer=current_app.config['SERVER_NAME'], - # options={'require': ['exp', 'iat', 'iss', 'sub']} - options={'require': ['exp', 'iat', 'iss']} - ) - except jwt.PyJWTError: - abort(410) - # permission = payload.get('sub') - if not current_user.is_following_corpus(corpus): + if not (current_user.is_authenticated and current_user.verify_follow_corpus_token(token)): + abort(403) + if not current_user.is_following_corpus(corpus) and current_user != corpus.user: current_user.follow_corpus(corpus) db.session.commit() flash(f'You are following {corpus.title} now', category='corpus') @@ -174,9 +164,6 @@ def create_corpus(): def corpus(corpus_id): corpus = Corpus.query.get_or_404(corpus_id) exp_date = (datetime.utcnow() + timedelta(days=7)).strftime('%b %d, %Y') - print(corpus.user) - print(current_user) - print(current_user.is_following_corpus(corpus)) if corpus.user == current_user or current_user.is_administrator(): return render_template( 'corpora/corpus.html.j2', @@ -201,18 +188,7 @@ def generate_corpus_share_link(corpus_id): # permission = data['permission'] exp_data = data['expiration'] expiration = datetime.strptime(exp_data, '%b %d, %Y') - now = datetime.utcnow() - payload = { - 'exp': expiration, - 'iat': now, - 'iss': current_app.config['SERVER_NAME'] - # 'sub': permission - } - token = jwt.encode( - payload, - current_app.config['SECRET_KEY'], - algorithm='HS256' - ) + token = current_user.generate_follow_corpus_token(corpus_id, expiration) link = url_for('corpora.follow_corpus', corpus_id=corpus_id, token=token, _external=True) return link diff --git a/app/models.py b/app/models.py index 2444589c..7dc6b0c3 100644 --- a/app/models.py +++ b/app/models.py @@ -1,6 +1,6 @@ from datetime import datetime, timedelta from enum import Enum, IntEnum -from flask import current_app, url_for +from flask import abort, current_app, url_for from flask_hashids import HashidMixin from flask_login import UserMixin from sqlalchemy.ext.associationproxy import association_proxy @@ -767,6 +767,37 @@ class User(HashidMixin, UserMixin, db.Model): def is_following_corpus(self, corpus): return corpus in self.followed_corpora + + def generate_follow_corpus_token(self, corpus_id, expiration=7): + now = datetime.utcnow() + payload = { + 'exp': expiration, + 'iat': now, + 'iss': current_app.config['SERVER_NAME'], + 'sub': corpus_id + } + return jwt.encode( + payload, + current_app.config['SECRET_KEY'], + algorithm='HS256' + ) + + def verify_follow_corpus_token(self, token): + try: + payload = jwt.decode( + token, + current_app.config['SECRET_KEY'], + algorithms=['HS256'], + issuer=current_app.config['SERVER_NAME'], + options={'require': ['exp', 'iat', 'iss', 'sub']} + ) + except jwt.PyJWTError: + return False + corpus_id = payload.get('sub') + corpus = Corpus.query.get_or_404(corpus_id) + if corpus is None: + return False + return True def to_json_serializeable(self, backrefs=False, relationships=False, filter_by_privacy_settings=False): json_serializeable = { From cb31afe7236fdf37a43db8b14fa3ecbebac91fac Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Fri, 24 Feb 2023 09:44:09 +0100 Subject: [PATCH 017/177] small fix --- app/models.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/models.py b/app/models.py index ed36a8ff..4f31c570 100644 --- a/app/models.py +++ b/app/models.py @@ -775,14 +775,17 @@ class User(HashidMixin, UserMixin, db.Model): #endregion Profile Privacy settings def follow_corpus(self, corpus, role=None): + if self.is_following_corpus(corpus): + return if role is None: r = CorpusFollowerRole.query.filter_by(default=True).first() cfa = CorpusFollowerAssociation(corpus=corpus, role=r, follower=self) db.session.add(cfa) def unfollow_corpus(self, corpus): - if self.is_following_corpus(corpus): - self.followed_corpora.remove(corpus) + if not self.is_following_corpus(corpus): + return + self.followed_corpora.remove(corpus) def is_following_corpus(self, corpus): return corpus in self.followed_corpora From 122cce98a160bf4432fa012eada338a61c58f769 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Fri, 24 Feb 2023 09:46:35 +0100 Subject: [PATCH 018/177] another small fix --- app/models.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/models.py b/app/models.py index 4f31c570..c71445f9 100644 --- a/app/models.py +++ b/app/models.py @@ -777,8 +777,7 @@ class User(HashidMixin, UserMixin, db.Model): def follow_corpus(self, corpus, role=None): if self.is_following_corpus(corpus): return - if role is None: - r = CorpusFollowerRole.query.filter_by(default=True).first() + r = CorpusFollowerRole.query.filter_by(default=True).first() if role is None else role cfa = CorpusFollowerAssociation(corpus=corpus, role=r, follower=self) db.session.add(cfa) From ff3ac3658fd22c3b27da0c9c8843944f14728d5e Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Fri, 24 Feb 2023 10:02:28 +0100 Subject: [PATCH 019/177] Yet another small fix --- app/models.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/models.py b/app/models.py index c71445f9..d73b01b6 100644 --- a/app/models.py +++ b/app/models.py @@ -542,7 +542,8 @@ class User(HashidMixin, UserMixin, db.Model): ) followed_corpora = association_proxy( 'corpus_follower_associations', - 'corpus' + 'corpus', + creator=lambda c: CorpusFollowerAssociation(corpus=c) ) jobs = db.relationship( 'Job', @@ -1499,7 +1500,8 @@ class Corpus(HashidMixin, db.Model): ) followers = association_proxy( 'corpus_follower_associations', - 'follower' + 'follower', + creator=lambda u: CorpusFollowerAssociation(follower=u) ) user = db.relationship('User', back_populates='corpora') # "static" attributes From c565b08f9c9aecfc384fce1a0a58189890aafb66 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Fri, 24 Feb 2023 10:30:42 +0100 Subject: [PATCH 020/177] Found a better solution for default role assignments --- app/models.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/app/models.py b/app/models.py index d73b01b6..df992ccb 100644 --- a/app/models.py +++ b/app/models.py @@ -479,6 +479,8 @@ class CorpusFollowerAssociation(HashidMixin, db.Model): ) def __init__(self, **kwargs): + if 'role' not in kwargs: + kwargs['role'] = CorpusFollowerRole.query.filter_by(default=True).first() super().__init__(**kwargs) def __repr__(self): @@ -575,13 +577,12 @@ class User(HashidMixin, UserMixin, db.Model): ) def __init__(self, **kwargs): + if 'role' not in kwargs: + if kwargs['email'] == current_app.config['NOPAQUE_ADMIN']: + kwargs['role'] = Role.query.filter_by(name='Administrator').first() + else: + kwargs['role'] = Role.query.filter_by(default=True).first() super().__init__(**kwargs) - if self.role is not None: - return - if self.email == current_app.config['NOPAQUE_ADMIN']: - self.role = Role.query.filter_by(name='Administrator').first() - else: - self.role = Role.query.filter_by(default=True).first() def __repr__(self): return f'' From 3147bed90acdd9a7321479274352a3b6d589760d Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Fri, 24 Feb 2023 10:34:42 +0100 Subject: [PATCH 021/177] codestyle enhancements --- app/models.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/models.py b/app/models.py index df992ccb..2800e767 100644 --- a/app/models.py +++ b/app/models.py @@ -578,10 +578,11 @@ class User(HashidMixin, UserMixin, db.Model): def __init__(self, **kwargs): if 'role' not in kwargs: - if kwargs['email'] == current_app.config['NOPAQUE_ADMIN']: - kwargs['role'] = Role.query.filter_by(name='Administrator').first() - else: - kwargs['role'] = Role.query.filter_by(default=True).first() + kwargs['role'] = ( + Role.query.filter_by(name='Administrator').first() + if kwargs['email'] == current_app.config['NOPAQUE_ADMIN'] + else Role.query.filter_by(default=True).first() + ) super().__init__(**kwargs) def __repr__(self): From e2ddbf26f1faf6d9b3adcdfa48bd8279514160fa Mon Sep 17 00:00:00 2001 From: Inga Kirschnick Date: Fri, 24 Feb 2023 15:22:26 +0100 Subject: [PATCH 022/177] Update User Card and Share link with specific role --- app/corpora/routes.py | 15 ++++-- app/models.py | 8 ++-- .../js/RessourceDisplays/CorpusDisplay.js | 5 +- app/static/js/Utils.js | 6 +-- app/templates/corpora/corpus.html.j2 | 14 +++--- app/templates/corpora/public_corpus.html.j2 | 46 +++++++++++++++++-- 6 files changed, 69 insertions(+), 25 deletions(-) diff --git a/app/corpora/routes.py b/app/corpora/routes.py index fd718145..ecb74cae 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -68,11 +68,12 @@ def disable_corpus_is_public(corpus_id): @bp.route('//follow/') @login_required def follow_corpus(corpus_id, token): - corpus = Corpus.query.get_or_404(corpus_id) + corpus = current_user.verify_follow_corpus_token(token)['corpus'] + role = current_user.verify_follow_corpus_token(token)['role'] if not (current_user.is_authenticated and current_user.verify_follow_corpus_token(token)): abort(403) if not current_user.is_following_corpus(corpus) and current_user != corpus.user: - current_user.follow_corpus(corpus) + current_user.follow_corpus(corpus, role) db.session.commit() flash(f'You are following {corpus.title} now', category='corpus') return redirect(url_for('corpora.corpus', corpus_id=corpus_id)) @@ -164,20 +165,24 @@ def create_corpus(): def corpus(corpus_id): corpus = Corpus.query.get_or_404(corpus_id) exp_date = (datetime.utcnow() + timedelta(days=7)).strftime('%b %d, %Y') + roles = [x.name for x in CorpusFollowerRole.query.all()] if corpus.user == current_user or current_user.is_administrator(): return render_template( 'corpora/corpus.html.j2', corpus=corpus, exp_date=exp_date, + roles=roles, title='Corpus' ) if current_user.is_following_corpus(corpus) or corpus.is_public: corpus_files = [x.to_json_serializeable() for x in corpus.files] + owner = corpus.user.to_json_serializeable() return render_template( 'corpora/public_corpus.html.j2', corpus=corpus, corpus_files=corpus_files, - title='Corpus' + owner=owner, + title='Corpus', ) abort(403) @@ -185,10 +190,10 @@ def corpus(corpus_id): @login_required def generate_corpus_share_link(corpus_id): data = request.get_json('data') - # permission = data['permission'] + role = data['role'] exp_data = data['expiration'] expiration = datetime.strptime(exp_data, '%b %d, %Y') - token = current_user.generate_follow_corpus_token(corpus_id, expiration) + token = current_user.generate_follow_corpus_token(corpus_id, role, expiration) link = url_for('corpora.follow_corpus', corpus_id=corpus_id, token=token, _external=True) return link diff --git a/app/models.py b/app/models.py index 74d82410..8fc08c18 100644 --- a/app/models.py +++ b/app/models.py @@ -789,13 +789,14 @@ class User(HashidMixin, UserMixin, db.Model): def is_following_corpus(self, corpus): return corpus in self.followed_corpora - def generate_follow_corpus_token(self, corpus_id, expiration=7): + def generate_follow_corpus_token(self, corpus_id, role, expiration=7): now = datetime.utcnow() payload = { 'exp': expiration, 'iat': now, 'iss': current_app.config['SERVER_NAME'], - 'sub': corpus_id + 'sub': corpus_id, + 'role': role } return jwt.encode( payload, @@ -816,9 +817,10 @@ class User(HashidMixin, UserMixin, db.Model): return False corpus_id = payload.get('sub') corpus = Corpus.query.get_or_404(corpus_id) + role = CorpusFollowerRole.query.filter_by(name=payload.get('role')).first() if corpus is None: return False - return True + return {'corpus': corpus, 'role': role} def to_json_serializeable(self, backrefs=False, relationships=False, filter_by_privacy_settings=False): json_serializeable = { diff --git a/app/static/js/RessourceDisplays/CorpusDisplay.js b/app/static/js/RessourceDisplays/CorpusDisplay.js index d42fa20c..4e8e8a9a 100644 --- a/app/static/js/RessourceDisplays/CorpusDisplay.js +++ b/app/static/js/RessourceDisplays/CorpusDisplay.js @@ -124,13 +124,12 @@ class CorpusDisplay extends RessourceDisplay { let copyShareLinkButton = this.displayElement.querySelector('#copy-share-link-button'); let shareLinkInput = this.displayElement.querySelector('#share-link-input'); let shareLinkContainer = this.displayElement.querySelector('#share-link-container'); - // let permissionSelect = this.displayElement.querySelector('#permission-select'); + let roleSelect = this.displayElement.querySelector('#role-select'); let expirationDate = this.displayElement.querySelector('#expiration'); generateShareLinkButton.addEventListener('click', () => { - // Utils.generateCorpusShareLinkRequest(`${this.corpusId}`, permissionSelect.value, expirationDate.value) - Utils.generateCorpusShareLinkRequest(`${this.corpusId}`, expirationDate.value) + Utils.generateCorpusShareLinkRequest(`${this.corpusId}`, roleSelect.value, expirationDate.value) .then((shareLink) => { shareLinkContainer.classList.remove('hide'); shareLinkInput.value = shareLink; diff --git a/app/static/js/Utils.js b/app/static/js/Utils.js index f8dbf2d0..865ea0d4 100644 --- a/app/static/js/Utils.js +++ b/app/static/js/Utils.js @@ -757,11 +757,9 @@ class Utils { }); } - // static generateCorpusShareLinkRequest(corpusId, permission, expiration) { - static generateCorpusShareLinkRequest(corpusId, expiration) { + static generateCorpusShareLinkRequest(corpusId, role, expiration) { return new Promise((resolve, reject) => { - // const data = {permission: permission, expiration: expiration}; - const data = {expiration: expiration}; + const data = {role: role, expiration: expiration}; fetch(`/corpora/${corpusId}/generate-corpus-share-link`, {method: 'POST', headers: {Accept: 'text/plain'}, body: JSON.stringify(data)}) .then( (response) => { diff --git a/app/templates/corpora/corpus.html.j2 b/app/templates/corpora/corpus.html.j2 index 196faef2..84950403 100644 --- a/app/templates/corpora/corpus.html.j2 +++ b/app/templates/corpora/corpus.html.j2 @@ -96,18 +96,18 @@

    - {#
    +
    - + {% for role in roles%} + + {% endfor %} - +
    -
    #} +
    diff --git a/app/templates/corpora/public_corpus.html.j2 b/app/templates/corpora/public_corpus.html.j2 index 63744441..17a7ae07 100644 --- a/app/templates/corpora/public_corpus.html.j2 +++ b/app/templates/corpora/public_corpus.html.j2 @@ -11,7 +11,7 @@
    {% if current_user.is_following_corpus(corpus) %} - addUnfollow Corpus + closeUnfollow 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 @@ -42,10 +42,50 @@
    - Corpus files -
    + Corpus Owner +
    +
    + + + + + + +
    + {% if corpus.user.avatar %} + user-image + {% else %} + user-image + {% endif %} + +
      +
    • {{ owner.username }}
    • + {% if owner.full_name %} +
    • {{ owner.full_name }}
    • + {% endif %} + {% if owner.show_email %} +
    • {{ owner.email }} + {% endif %} +
    +
    + {% if not current_user.is_following_corpus(corpus) %} +
    +

    + Request Corpus + {% endif %} +
    +
    + + {% if current_user.is_following_corpus(corpus)%} +
    +
    + Corpus files +
    +
    +
    + {% endif %}
    From d2828cabbe6d1f7a14bf859d474a5106e90e41a7 Mon Sep 17 00:00:00 2001 From: Inga Kirschnick Date: Fri, 24 Feb 2023 15:46:44 +0100 Subject: [PATCH 023/177] Explanation update and profile link button --- app/templates/corpora/corpus.html.j2 | 11 +++++++++++ app/templates/corpora/public_corpus.html.j2 | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/templates/corpora/corpus.html.j2 b/app/templates/corpora/corpus.html.j2 index 84950403..790d4fac 100644 --- a/app/templates/corpora/corpus.html.j2 +++ b/app/templates/corpora/corpus.html.j2 @@ -86,6 +86,9 @@ Share your Corpus

    +

    Change your Corpus Status to Public

    +

    Other users can only see the meta data of your corpus. The files of the corpus remain private and can only be viewed via a share link.

    +

    +
    +
    +

    +

    Create a link to share your corpus files with your team

    +

    With the link other users follow your corpus directly, if it has not expired. + You can set different roles via the link, you can also edit them later in the menu below. + It is recommended not to set the expiration date of the link too far.

    +
    diff --git a/app/templates/corpora/public_corpus.html.j2 b/app/templates/corpora/public_corpus.html.j2 index 17a7ae07..edf6ed74 100644 --- a/app/templates/corpora/public_corpus.html.j2 +++ b/app/templates/corpora/public_corpus.html.j2 @@ -68,11 +68,12 @@ - {% if not current_user.is_following_corpus(corpus) %}

    + {% if not current_user.is_following_corpus(corpus) %} Request Corpus {% endif %} + View profile
    From ec6d0a6477763c5bec3c1c5d82a14cc51816ecdf Mon Sep 17 00:00:00 2001 From: Inga Kirschnick Date: Tue, 28 Feb 2023 10:27:10 +0100 Subject: [PATCH 024/177] corpus permission decorator + sidenav update --- app/corpora/decorators.py | 37 ++++++++++++++++++++++++++++++++++ app/corpora/routes.py | 14 +++++++++---- app/templates/_sidenav.html.j2 | 17 ++++++++++------ 3 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 app/corpora/decorators.py diff --git a/app/corpora/decorators.py b/app/corpora/decorators.py new file mode 100644 index 00000000..3d841e9b --- /dev/null +++ b/app/corpora/decorators.py @@ -0,0 +1,37 @@ +from flask import abort, current_app +from flask_login import current_user +from functools import wraps +from app.models import Corpus, CorpusFollowerAssociation + +def corpus_follower_permission_required(permissions): + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + corpus_id = kwargs.get('corpus_id') + corpus = Corpus.query.get_or_404(corpus_id) + if current_user == corpus.user or current_user.is_administrator(): + print('user or admin') + return f(*args, **kwargs) + if not current_user.is_following_corpus(corpus): + print('not following corpus') + abort(403) + corpus_follower_association = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=current_user.id).first_or_404() + for permission in permissions: + if not corpus_follower_association.role.has_permission(permission): + abort(403) + return f(*args, **kwargs) + return decorated_function + return decorator + +def owner_or_admin_required(): + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + corpus_id = kwargs.get('corpus_id') + corpus = Corpus.query.get_or_404(corpus_id) + if current_user == corpus.user or current_user.is_administrator(): + return f(*args, **kwargs) + abort(403) + return decorated_function + return decorator + diff --git a/app/corpora/routes.py b/app/corpora/routes.py index ecb74cae..9a69ebd8 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -15,6 +15,7 @@ from flask_login import current_user, login_required from threading import Thread import jwt import os +from .decorators import corpus_follower_permission_required, owner_or_admin_required from app import db, hashids from app.models import ( Corpus, @@ -32,6 +33,11 @@ from .forms import ( UpdateCorpusFileForm ) +@bp.route('//test') +@login_required +@corpus_follower_permission_required(['VIEW', 'ADD_CORPUS_FILE']) +def test(corpus_id): + return 'ok' @bp.route('/fake-add') @login_required @@ -45,10 +51,9 @@ def fake_add(): @bp.route('//is_public/enable', methods=['POST']) @login_required +@owner_or_admin_required() 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 @@ -56,10 +61,9 @@ def enable_corpus_is_public(corpus_id): @bp.route('//is_public/disable', methods=['POST']) @login_required +@owner_or_admin_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 @@ -107,6 +111,7 @@ def current_user_unfollow_corpus(corpus_id): @bp.route('//followers//role', methods=['POST']) +@corpus_follower_permission_required(['REMOVE_FOLLOWER', 'UPDATE_FOLLOWER']) def add_permission(corpus_id, follower_id): corpus_follower_association = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=follower_id).first_or_404() if not (corpus_follower_association.corpus.user == current_user or current_user.is_administrator()): @@ -188,6 +193,7 @@ def corpus(corpus_id): @bp.route('//generate-corpus-share-link', methods=['GET', 'POST']) @login_required +@corpus_follower_permission_required('GENERATE_SHARE_LINK') def generate_corpus_share_link(corpus_id): data = request.get_json('data') role = data['role'] diff --git a/app/templates/_sidenav.html.j2 b/app/templates/_sidenav.html.j2 index 7fa34623..18b902af 100644 --- a/app/templates/_sidenav.html.j2 +++ b/app/templates/_sidenav.html.j2 @@ -3,11 +3,17 @@
    - - - +
    {{ current_user.username }}
    @@ -20,7 +26,6 @@
  • dashboardDashboard
  • IMy Corpora
  • JMy Jobs
  • -
  • groupsSocial
  • new_labelContribute
  • Processes & Services
  • From b1586b3679fd8c1c3089739433eb302de3218fac Mon Sep 17 00:00:00 2001 From: Inga Kirschnick Date: Wed, 1 Mar 2023 14:09:15 +0100 Subject: [PATCH 025/177] social-area page and profile page update --- app/corpora/decorators.py | 11 ++- app/corpora/routes.py | 17 ++--- app/main/routes.py | 32 +++++---- .../js/ResourceLists/FollowedCorpusList.js | 14 ++++ app/static/js/ResourceLists/UserList.js | 6 +- app/templates/_scripts.html.j2 | 1 + app/templates/_sidenav.html.j2 | 6 +- app/templates/main/social_area.html.j2 | 70 +++++++++++++++++++ app/templates/users/profile.html.j2 | 14 ++-- app/users/routes.py | 31 ++++---- 10 files changed, 144 insertions(+), 58 deletions(-) create mode 100644 app/static/js/ResourceLists/FollowedCorpusList.js create mode 100644 app/templates/main/social_area.html.j2 diff --git a/app/corpora/decorators.py b/app/corpora/decorators.py index 3d841e9b..b3499258 100644 --- a/app/corpora/decorators.py +++ b/app/corpora/decorators.py @@ -3,27 +3,24 @@ from flask_login import current_user from functools import wraps from app.models import Corpus, CorpusFollowerAssociation -def corpus_follower_permission_required(permissions): +def corpus_follower_permission_required(*permissions): def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): corpus_id = kwargs.get('corpus_id') corpus = Corpus.query.get_or_404(corpus_id) if current_user == corpus.user or current_user.is_administrator(): - print('user or admin') return f(*args, **kwargs) if not current_user.is_following_corpus(corpus): - print('not following corpus') abort(403) corpus_follower_association = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=current_user.id).first_or_404() - for permission in permissions: - if not corpus_follower_association.role.has_permission(permission): - abort(403) + if not all([corpus_follower_association.role.has_permission(p) for p in permissions]): + abort(403) return f(*args, **kwargs) return decorated_function return decorator -def owner_or_admin_required(): +def corpus_owner_or_admin_required(): def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): diff --git a/app/corpora/routes.py b/app/corpora/routes.py index 9a69ebd8..c2af713a 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -15,7 +15,7 @@ from flask_login import current_user, login_required from threading import Thread import jwt import os -from .decorators import corpus_follower_permission_required, owner_or_admin_required +from .decorators import corpus_follower_permission_required, corpus_owner_or_admin_required from app import db, hashids from app.models import ( Corpus, @@ -33,12 +33,6 @@ from .forms import ( UpdateCorpusFileForm ) -@bp.route('//test') -@login_required -@corpus_follower_permission_required(['VIEW', 'ADD_CORPUS_FILE']) -def test(corpus_id): - return 'ok' - @bp.route('/fake-add') @login_required def fake_add(): @@ -51,7 +45,7 @@ def fake_add(): @bp.route('//is_public/enable', methods=['POST']) @login_required -@owner_or_admin_required() +@corpus_owner_or_admin_required() def enable_corpus_is_public(corpus_id): corpus = Corpus.query.get_or_404(corpus_id) corpus.is_public = True @@ -61,7 +55,7 @@ def enable_corpus_is_public(corpus_id): @bp.route('//is_public/disable', methods=['POST']) @login_required -@owner_or_admin_required() +@corpus_owner_or_admin_required() def disable_corpus_is_public(corpus_id): corpus = Corpus.query.get_or_404(corpus_id) corpus.is_public = False @@ -111,7 +105,7 @@ def current_user_unfollow_corpus(corpus_id): @bp.route('//followers//role', methods=['POST']) -@corpus_follower_permission_required(['REMOVE_FOLLOWER', 'UPDATE_FOLLOWER']) +@corpus_follower_permission_required('REMOVE_FOLLOWER', 'UPDATE_FOLLOWER') def add_permission(corpus_id, follower_id): corpus_follower_association = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=follower_id).first_or_404() if not (corpus_follower_association.corpus.user == current_user or current_user.is_administrator()): @@ -206,6 +200,7 @@ def generate_corpus_share_link(corpus_id): @bp.route('/', methods=['DELETE']) @login_required +@corpus_owner_or_admin_required() def delete_corpus(corpus_id): def _delete_corpus(app, corpus_id): with app.app_context(): @@ -214,8 +209,6 @@ def delete_corpus(corpus_id): db.session.commit() corpus = Corpus.query.get_or_404(corpus_id) - if not (corpus.user == current_user or current_user.is_administrator()): - abort(403) thread = Thread( target=_delete_corpus, args=(current_app._get_current_object(), corpus_id) diff --git a/app/main/routes.py b/app/main/routes.py index eff0dadd..287a04f5 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -27,20 +27,7 @@ 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, Corpus.user != current_user).all() - # ] - return render_template( - 'main/dashboard.html.j2', - title='Dashboard', - # users=users, - # corpora=corpora - ) + return render_template('main/dashboard.html.j2', title='Dashboard') @bp.route('/dashboard2') @@ -67,3 +54,20 @@ def privacy_policy(): @bp.route('/terms_of_use') def terms_of_use(): return render_template('main/terms_of_use.html.j2', title='Terms of Use') + +@bp.route('/social-area') +def social_area(): + users = [ + u.to_json_serializeable(relationships=True, 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/social_area.html.j2', + users=users, + corpora=corpora, + title='Social Area' + ) diff --git a/app/static/js/ResourceLists/FollowedCorpusList.js b/app/static/js/ResourceLists/FollowedCorpusList.js new file mode 100644 index 00000000..b48bfd76 --- /dev/null +++ b/app/static/js/ResourceLists/FollowedCorpusList.js @@ -0,0 +1,14 @@ +class FollowedCorpusList extends CorpusList { + get item() { + return ` + + book +
    + + + send + + + `.trim(); + } +} diff --git a/app/static/js/ResourceLists/UserList.js b/app/static/js/ResourceLists/UserList.js index 03fabd73..65542de0 100644 --- a/app/static/js/ResourceLists/UserList.js +++ b/app/static/js/ResourceLists/UserList.js @@ -13,14 +13,14 @@ class UserList extends ResourceList { get item() { return ` - user-image + user-image - send + send `.trim(); @@ -77,7 +77,7 @@ class UserList extends ResourceList { 'full-name': user.full_name ? user.full_name : '', 'location': user.location ? user.location : '', 'organization': user.organization ? user.organization : '', - 'corpora-online': '-' + 'corpora-online': Object.values(user.corpora).filter((corpus) => corpus.is_public).length }; }; diff --git a/app/templates/_scripts.html.j2 b/app/templates/_scripts.html.j2 index 2a3626ab..18cde5b8 100644 --- a/app/templates/_scripts.html.j2 +++ b/app/templates/_scripts.html.j2 @@ -22,6 +22,7 @@ 'js/ResourceLists/CorpusFileList.js', 'js/ResourceLists/PublicCorpusFileList.js', 'js/ResourceLists/CorpusList.js', + 'js/ResourceLists/FollowedCorpusList.js', 'js/ResourceLists/PublicCorpusList.js', 'js/ResourceLists/JobList.js', 'js/ResourceLists/JobInputList.js', diff --git a/app/templates/_sidenav.html.j2 b/app/templates/_sidenav.html.j2 index 18b902af..62dcc604 100644 --- a/app/templates/_sidenav.html.j2 +++ b/app/templates/_sidenav.html.j2 @@ -6,11 +6,10 @@ #}
    @@ -27,6 +26,7 @@
  • IMy Corpora
  • JMy Jobs
  • new_labelContribute
  • +
  • groupSocial Area
  • Processes & Services
  • File setup
  • diff --git a/app/templates/main/social_area.html.j2 b/app/templates/main/social_area.html.j2 new file mode 100644 index 00000000..fbe6974e --- /dev/null +++ b/app/templates/main/social_area.html.j2 @@ -0,0 +1,70 @@ +{% extends "base.html.j2" %} +{% import "materialize/wtf.html.j2" as wtf %} + +{% block main_attribs %} style="background-color:#d8c9ba86" {% endblock main_attribs %} + +{% block page_content %} +
    +
    +
    +

    {{ title }}

    +
    + +
    +
    +

     

    +

     

    + + group + +
    +
    + +
    +
    +
    +
    +
    +
    + layersYour social area +

    Here you can network with your team and other users. You can find corpora that are public and request them or just see what other users are working on.

    +
    +
    +
    +
    +
    +
    + +
    +

    Other Users

    +

    Find other users and see what corpora they have made public.

    +
    +
    +
    +
    +
    +
    + +
    +

    Public Corpora

    +

    Find public corpora.

    +
    +
    + Public Corpora +
    +
    +
    +
    +
    +
    +{% endblock page_content %} + +{% block scripts %} +{{ super() }} + +{% endblock scripts %} diff --git a/app/templates/users/profile.html.j2 b/app/templates/users/profile.html.j2 index a6ad5b30..4a9a4c13 100644 --- a/app/templates/users/profile.html.j2 +++ b/app/templates/users/profile.html.j2 @@ -26,7 +26,7 @@
    {% if user.show_last_seen %} -
    Last seen: {{ user.last_seen }}
    +
    Last seen: {{ last_seen }}
    {% endif %} {% if user.location %}

    location_on{{ user.location }}

    @@ -76,7 +76,7 @@
    {% if user.show_member_since %} -

    Member since: {{ user.member_since }}

    +

    Member since: {{ member_since }}

    {% endif %}


    @@ -93,7 +93,8 @@
    -

    Groups

    +

    Followed corpora

    +
    @@ -101,7 +102,7 @@

    Public corpora

    -
    +
    @@ -127,6 +128,11 @@ if ("{{ user.id }}" == "{{ current_user.hashid }}") { } else { publicInformationBadge.remove(); } + +let followedCorpusList = new FollowedCorpusList(document.querySelector('.followed-corpus-list')); +followedCorpusList.add({{ followed_corpora|tojson }}); +let publicCorpusList = new PublicCorpusList(document.querySelector('.public-corpus-list')); +publicCorpusList.add({{ own_public_corpora|tojson }}); {% endblock scripts %} diff --git a/app/users/routes.py b/app/users/routes.py index 48a8d7b5..d573da34 100644 --- a/app/users/routes.py +++ b/app/users/routes.py @@ -1,3 +1,4 @@ +from datetime import datetime from flask import ( abort, current_app, @@ -12,7 +13,7 @@ from flask_login import current_user, login_required from threading import Thread import os from app import db -from app.models import Avatar, ProfilePrivacySettings, User +from app.models import Avatar, Corpus, ProfilePrivacySettings, User from . import bp from .forms import ( EditPrivacySettingsForm, @@ -29,10 +30,23 @@ def before_request(): @login_required def user(user_id): user = User.query.get_or_404(user_id) + last_seen = user.last_seen.strftime('%Y-%m-%d %H:%M') + member_since = user.member_since.strftime('%Y-%m-%d') + followed_corpora = [ + c.to_json_serializeable() for c in user.followed_corpora + ] + own_public_corpora = [ + c.to_json_serializeable() for c + in Corpus.query.filter_by(is_public = True, user = user).all() + ] if not user.is_public and user != current_user: abort(403) return render_template( - 'users/profile.html.j2', + 'users/profile.html.j2', + followed_corpora=followed_corpora, + last_seen=last_seen, + member_since=member_since, + own_public_corpora=own_public_corpora, user=user.to_json_serializeable(), user_id=user_id ) @@ -56,18 +70,6 @@ def delete_user(user_id): thread.start() return {}, 202 -@bp.route('/') -def profile(user_id): - user = User.query.get_or_404(user_id) - if not user.is_public and user != current_user: - abort(403) - return render_template( - 'users/profile.html.j2', - user=user.to_json_serializeable(), - user_id=user_id - ) - - @bp.route('//avatar') def profile_avatar(user_id): user = User.query.get_or_404(user_id) @@ -91,7 +93,6 @@ def delete_profile_avatar(user_id): avatar = Avatar.query.get(avatar_id) avatar.delete() db.session.commit() - user = User.query.get_or_404(user_id) if user.avatar is None: abort(404) From ed195af6a2b778ea58673e8876b1b38ccf147c10 Mon Sep 17 00:00:00 2001 From: Inga Kirschnick Date: Wed, 1 Mar 2023 15:14:51 +0100 Subject: [PATCH 026/177] corpus follower permission decorator update --- app/corpora/routes.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/corpora/routes.py b/app/corpora/routes.py index c2af713a..5446f144 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -219,12 +219,9 @@ def delete_corpus(corpus_id): @bp.route('//analyse') @login_required +@corpus_follower_permission_required('VIEW') 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)): - abort(403) return render_template( 'corpora/analyse_corpus.html.j2', corpus=corpus, @@ -234,6 +231,7 @@ def analyse_corpus(corpus_id): @bp.route('//build', methods=['POST']) @login_required +@corpus_owner_or_admin_required() def build_corpus(corpus_id): def _build_corpus(app, corpus_id): with app.app_context(): @@ -258,6 +256,7 @@ def build_corpus(corpus_id): @bp.route('//files/create', methods=['GET', 'POST']) @login_required +@corpus_follower_permission_required('ADD_CORPUS_FILE') def create_corpus_file(corpus_id): corpus = Corpus.query.get_or_404(corpus_id) if not (corpus.user == current_user or current_user.is_administrator()): @@ -305,10 +304,9 @@ def create_corpus_file(corpus_id): @bp.route('//files/', methods=['GET', 'POST']) @login_required +@corpus_follower_permission_required('ADD_CORPUS_FILE', 'UPDATE_CORPUS_FILE', 'REMOVE_CORPUS_FILE') def corpus_file(corpus_id, corpus_file_id): corpus_file = CorpusFile.query.filter_by(corpus_id = corpus_id, id=corpus_file_id).first_or_404() - if not (corpus_file.corpus.user == current_user or current_user.is_administrator()): - abort(403) form = UpdateCorpusFileForm(data=corpus_file.to_json_serializeable()) if form.validate_on_submit(): form.populate_obj(corpus_file) @@ -329,6 +327,7 @@ def corpus_file(corpus_id, corpus_file_id): @bp.route('//files/', methods=['DELETE']) @login_required +@corpus_follower_permission_required('REMOVE_CORPUS_FILE') def delete_corpus_file(corpus_id, corpus_file_id): def _delete_corpus_file(app, corpus_file_id): with app.app_context(): @@ -349,6 +348,7 @@ def delete_corpus_file(corpus_id, corpus_file_id): @bp.route('//files//download') @login_required +@corpus_follower_permission_required('VIEW') def download_corpus_file(corpus_id, corpus_file_id): corpus_file = CorpusFile.query.filter_by(corpus_id = corpus_id, id=corpus_file_id).first_or_404() if not (corpus_file.corpus.user == current_user or current_user.is_administrator()): From 145b80356d6809a3885681d5df2391d2e25bcab8 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Wed, 1 Mar 2023 16:31:41 +0100 Subject: [PATCH 027/177] Redesign corpus page and add possibility to add followers by username for owner --- app/corpora/routes.py | 32 +- .../js/RessourceDisplays/CorpusDisplay.js | 37 +- app/static/js/Utils.js | 118 +++---- app/templates/corpora/corpus.html.j2 | 327 +++++++++++++----- 4 files changed, 313 insertions(+), 201 deletions(-) diff --git a/app/corpora/routes.py b/app/corpora/routes.py index 9a69ebd8..1ea34a4d 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -3,6 +3,7 @@ from flask import ( abort, current_app, flash, + jsonify, make_response, Markup, redirect, @@ -49,22 +50,34 @@ def fake_add(): return '' -@bp.route('//is_public/enable', methods=['POST']) +@bp.route('//is_public', methods=['POST']) @login_required @owner_or_admin_required() -def enable_corpus_is_public(corpus_id): +def update_corpus_is_public(corpus_id): + is_public = request.json + if not isinstance(is_public, bool): + response = jsonify('The request body must be a boolean') + response.status_code = 400 + abort(response) corpus = Corpus.query.get_or_404(corpus_id) - corpus.is_public = True + corpus.is_public = is_public db.session.commit() return '', 204 -@bp.route('//is_public/disable', methods=['POST']) +@bp.route('//followers/add', methods=['POST']) @login_required @owner_or_admin_required() -def disable_corpus_is_public(corpus_id): +def add_corpus_followers(corpus_id): + usernames = request.json + if not (isinstance(usernames, list) or all(isinstance(u, str) for u in usernames)): + response = jsonify('The request body must be a list of strings') + response.status_code = 400 + abort(response) corpus = Corpus.query.get_or_404(corpus_id) - corpus.is_public = False + for username in usernames: + user = User.query.filter_by(username=username, is_public=True).first_or_404() + user.follow_corpus(corpus) db.session.commit() return '', 204 @@ -169,14 +182,12 @@ def create_corpus(): @login_required def corpus(corpus_id): corpus = Corpus.query.get_or_404(corpus_id) - exp_date = (datetime.utcnow() + timedelta(days=7)).strftime('%b %d, %Y') - roles = [x.name for x in CorpusFollowerRole.query.all()] + corpus_follower_roles = CorpusFollowerRole.query.all() if corpus.user == current_user or current_user.is_administrator(): return render_template( 'corpora/corpus.html.j2', corpus=corpus, - exp_date=exp_date, - roles=roles, + corpus_follower_roles=corpus_follower_roles, title='Corpus' ) if current_user.is_following_corpus(corpus) or corpus.is_public: @@ -191,6 +202,7 @@ def corpus(corpus_id): ) abort(403) + @bp.route('//generate-corpus-share-link', methods=['GET', 'POST']) @login_required @corpus_follower_permission_required('GENERATE_SHARE_LINK') diff --git a/app/static/js/RessourceDisplays/CorpusDisplay.js b/app/static/js/RessourceDisplays/CorpusDisplay.js index 4e8e8a9a..ab462e9b 100644 --- a/app/static/js/RessourceDisplays/CorpusDisplay.js +++ b/app/static/js/RessourceDisplays/CorpusDisplay.js @@ -12,16 +12,6 @@ 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) { @@ -31,7 +21,6 @@ class CorpusDisplay extends RessourceDisplay { this.setStatus(corpus.status); this.setTitle(corpus.title); this.setNumTokens(corpus.num_tokens); - this.setShareLink(); } onPatch(patch) { @@ -82,7 +71,7 @@ class CorpusDisplay extends RessourceDisplay { } setStatus(status) { - let elements = this.displayElement.querySelectorAll('.corpus-analyse-trigger') + let elements = this.displayElement.querySelectorAll('.action-button[data-action="analyze"]'); for (let element of elements) { if (['BUILT', 'STARTING_ANALYSIS_SESSION', 'RUNNING_ANALYSIS_SESSION', 'CANCELING_ANALYSIS_SESSION'].includes(status)) { element.classList.remove('disabled'); @@ -118,28 +107,4 @@ class CorpusDisplay extends RessourceDisplay { new Date(creationDate).toLocaleString("en-US") ); } - - setShareLink() { - let generateShareLinkButton = this.displayElement.querySelector('#generate-share-link-button'); - let copyShareLinkButton = this.displayElement.querySelector('#copy-share-link-button'); - let shareLinkInput = this.displayElement.querySelector('#share-link-input'); - let shareLinkContainer = this.displayElement.querySelector('#share-link-container'); - let roleSelect = this.displayElement.querySelector('#role-select'); - let expirationDate = this.displayElement.querySelector('#expiration'); - - - generateShareLinkButton.addEventListener('click', () => { - Utils.generateCorpusShareLinkRequest(`${this.corpusId}`, roleSelect.value, expirationDate.value) - .then((shareLink) => { - shareLinkContainer.classList.remove('hide'); - shareLinkInput.value = shareLink; - }); - }); - - copyShareLinkButton.addEventListener('click', () => { - shareLinkInput.select(); - navigator.clipboard.writeText(shareLinkInput.value); - app.flash(`Copied!`, 'success'); - }); - } } diff --git a/app/static/js/Utils.js b/app/static/js/Utils.js index 865ea0d4..c5a55e89 100644 --- a/app/static/js/Utils.js +++ b/app/static/js/Utils.js @@ -69,6 +69,36 @@ class Utils { return Utils.mergeObjectsDeep(mergedObject, ...objects.slice(2)); } + static updateCorpusIsPublicRequest(corpusId, isPublic) { + return new Promise((resolve, reject) => { + let fetchRessource = `/corpora/${corpusId}/is_public`; + let fetchOptions = { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(isPublic) + }; + fetch(fetchRessource, fetchOptions) + .then( + (response) => { + if (response.ok) { + app.flash(`Corpus is now ${isPublic ? 'public' : 'private'}`, 'corpus'); + resolve(response); + } else { + app.flash(`${response.statusText}`, 'error'); + reject(response); + } + }, + (response) => { + app.flash('Something went wrong', 'error'); + reject(response); + } + ); + }); + } + static updateCorpusFollowerRole(corpusId, followerId, roleName) { return new Promise((resolve, reject) => { fetch(`/corpora/${corpusId}/followers/${followerId}/role`, {method: 'POST', headers: {Accept: 'application/json', 'Content-Type': 'application/json'}, body: JSON.stringify({role: roleName})}) @@ -91,79 +121,27 @@ class Utils { }); } - static enableCorpusIsPublicRequest(userId, corpusId) { + static addCorpusFollowersRequest(corpusId, usernames) { 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}/is_public/enable`, {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}/is_public/disable`, {method: 'POST', headers: {Accept: 'application/json'}}) + let fetchRessource = `/corpora/${corpusId}/followers/add`; + let fetchOptions = { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(usernames) + }; + fetch(fetchRessource, fetchOptions) .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); + if (response.ok) { + app.flash(`${usernames.length > 1 ? 'Users are' : 'User is'} following now`, 'corpus'); + resolve(response); + } else { + app.flash(`${response.statusText}`, 'error'); + reject(response); + } }, (response) => { app.flash('Something went wrong', 'error'); diff --git a/app/templates/corpora/corpus.html.j2 b/app/templates/corpora/corpus.html.j2 index 790d4fac..299c86f8 100644 --- a/app/templates/corpora/corpus.html.j2 +++ b/app/templates/corpora/corpus.html.j2 @@ -8,13 +8,11 @@
    -
    -
    -

    -
    -
    -

     

    -

     

    +

    +
    +
    +
    +
    @@ -29,11 +27,6 @@
    -
    -
    - -
    -
    @@ -57,12 +50,35 @@
    - + +
    +
    +
    + Actions + + Social +
    @@ -80,73 +96,8 @@
    -
    -
    -
    - Share your Corpus -
    -

    -

    Change your Corpus Status to Public

    -

    Other users can only see the meta data of your corpus. The files of the corpus remain private and can only be viewed via a share link.

    -
    -
    - - -
    -
    -

    -
    -
    -

    -

    Create a link to share your corpus files with your team

    -

    With the link other users follow your corpus directly, if it has not expired. - You can set different roles via the link, you can also edit them later in the menu below. - It is recommended not to set the expiration date of the link too far.

    -
    -
    -
    -
    - - -
    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    +
    +
    @@ -159,9 +110,215 @@
    {% endblock page_content %} +{% block modals %} +{{ super() }} + + + + + +{% endblock modals %} + {% block scripts %} {{ super() }} {% endblock scripts %} From c01068e96b8b0ff5c48dc8f65953fd3a0bc991e5 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Wed, 1 Mar 2023 16:33:29 +0100 Subject: [PATCH 028/177] cleanup imports --- app/corpora/routes.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/corpora/routes.py b/app/corpora/routes.py index 69a90767..a959da77 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -1,10 +1,9 @@ -from datetime import datetime, timedelta +from datetime import datetime from flask import ( abort, current_app, flash, jsonify, - make_response, Markup, redirect, render_template, @@ -14,15 +13,13 @@ from flask import ( ) from flask_login import current_user, login_required from threading import Thread -import jwt import os from .decorators import corpus_follower_permission_required, corpus_owner_or_admin_required -from app import db, hashids +from app import db from app.models import ( Corpus, CorpusFile, CorpusFollowerAssociation, - CorpusFollowerPermission, CorpusFollowerRole, CorpusStatus, User From 2dc7efbc8d89b8927d4ab9bea13641f86adabdda Mon Sep 17 00:00:00 2001 From: Inga Kirschnick Date: Thu, 2 Mar 2023 09:57:43 +0100 Subject: [PATCH 029/177] Update follow corpus by token method --- app/corpora/routes.py | 12 +++++------- app/models.py | 25 +++++++++++++++++-------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/app/corpora/routes.py b/app/corpora/routes.py index 5446f144..92664d43 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -66,15 +66,11 @@ def disable_corpus_is_public(corpus_id): @bp.route('//follow/') @login_required def follow_corpus(corpus_id, token): - corpus = current_user.verify_follow_corpus_token(token)['corpus'] - role = current_user.verify_follow_corpus_token(token)['role'] - if not (current_user.is_authenticated and current_user.verify_follow_corpus_token(token)): - abort(403) - if not current_user.is_following_corpus(corpus) and current_user != corpus.user: - current_user.follow_corpus(corpus, role) + if current_user.follow_corpus_by_token(token): db.session.commit() flash(f'You are following {corpus.title} now', category='corpus') - return redirect(url_for('corpora.corpus', corpus_id=corpus_id)) + return redirect(url_for('corpora.corpus', corpus_id=corpus_id)) + abort(403) @bp.route('//followers//unfollow', methods=['POST']) @@ -174,12 +170,14 @@ def corpus(corpus_id): title='Corpus' ) if current_user.is_following_corpus(corpus) or corpus.is_public: + cfa = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=current_user.id).first_or_404() corpus_files = [x.to_json_serializeable() for x in corpus.files] owner = corpus.user.to_json_serializeable() return render_template( 'corpora/public_corpus.html.j2', corpus=corpus, corpus_files=corpus_files, + cfa=cfa, owner=owner, title='Corpus', ) diff --git a/app/models.py b/app/models.py index d363c0bb..de995a5c 100644 --- a/app/models.py +++ b/app/models.py @@ -792,14 +792,15 @@ class User(HashidMixin, UserMixin, db.Model): def is_following_corpus(self, corpus): return corpus in self.followed_corpora - def generate_follow_corpus_token(self, corpus_id, role, expiration=7): + def generate_follow_corpus_token(self, corpus_hashid, role_name, expiration=7): now = datetime.utcnow() payload = { 'exp': expiration, 'iat': now, 'iss': current_app.config['SERVER_NAME'], - 'sub': corpus_id, - 'role': role + 'purpose': 'User.follow_corpus', + 'role_name': role_name, + 'sub': corpus_hashid } return jwt.encode( payload, @@ -807,23 +808,31 @@ class User(HashidMixin, UserMixin, db.Model): algorithm='HS256' ) - def verify_follow_corpus_token(self, token): + def follow_corpus_by_token(self, token): try: payload = jwt.decode( token, current_app.config['SECRET_KEY'], algorithms=['HS256'], issuer=current_app.config['SERVER_NAME'], - options={'require': ['exp', 'iat', 'iss', 'sub']} + options={'require': ['exp', 'iat', 'iss', 'purpose', 'role_name', 'sub']} ) except jwt.PyJWTError: return False - corpus_id = payload.get('sub') + if payload.get('purpose') != 'User.follow_corpus': + return False + corpus_hashid = payload.get('sub') + corpus_id = hashids.decode(corpus_hashid) corpus = Corpus.query.get_or_404(corpus_id) - role = CorpusFollowerRole.query.filter_by(name=payload.get('role')).first() if corpus is None: return False - return {'corpus': corpus, 'role': role} + role_name = payload.get('role_name') + role = CorpusFollowerRole.query.filter_by(name=role_name).first() + if role is None: + return False + self.follow_corpus(corpus, role) + db.session.add(self) + return True def to_json_serializeable(self, backrefs=False, relationships=False, filter_by_privacy_settings=False): json_serializeable = { From b364480de6c19e5b0106a5db9a018b4ad1633bae Mon Sep 17 00:00:00 2001 From: Inga Kirschnick Date: Thu, 2 Mar 2023 09:59:47 +0100 Subject: [PATCH 030/177] Public Page update --- app/templates/corpora/public_corpus.html.j2 | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/templates/corpora/public_corpus.html.j2 b/app/templates/corpora/public_corpus.html.j2 index edf6ed74..1f73cf9d 100644 --- a/app/templates/corpora/public_corpus.html.j2 +++ b/app/templates/corpora/public_corpus.html.j2 @@ -79,7 +79,7 @@
    - {% if current_user.is_following_corpus(corpus)%} + {% if cfa.role.has_permission('VIEW') %}
    Corpus files @@ -87,7 +87,16 @@
    {% endif %} -
    + + {% if cfa.role.has_permission('UPDATE_FOLLOWER') %} +
    +
    + Corpus followers +
    +
    +
    + {% endif %} +
    From 8a55ce902e0805e1351a6f3e6a69317fbd9c971a Mon Sep 17 00:00:00 2001 From: Inga Kirschnick Date: Thu, 2 Mar 2023 15:08:50 +0100 Subject: [PATCH 031/177] color SCSS update social area+ decorator fix --- app/corpora/decorators.py | 24 ++++++++++++------------ app/corpora/routes.py | 8 ++++---- app/static/css/colors.scss | 15 +++++++++++++++ app/static/js/ResourceLists/UserList.js | 2 +- app/templates/_sidenav.html.j2 | 4 ++-- app/templates/main/social_area.html.j2 | 6 +++--- 6 files changed, 37 insertions(+), 22 deletions(-) diff --git a/app/corpora/decorators.py b/app/corpora/decorators.py index b3499258..96d4ac69 100644 --- a/app/corpora/decorators.py +++ b/app/corpora/decorators.py @@ -1,8 +1,9 @@ -from flask import abort, current_app +from flask import abort from flask_login import current_user from functools import wraps from app.models import Corpus, CorpusFollowerAssociation + def corpus_follower_permission_required(*permissions): def decorator(f): @wraps(f) @@ -20,15 +21,14 @@ def corpus_follower_permission_required(*permissions): return decorated_function return decorator -def corpus_owner_or_admin_required(): - def decorator(f): - @wraps(f) - def decorated_function(*args, **kwargs): - corpus_id = kwargs.get('corpus_id') - corpus = Corpus.query.get_or_404(corpus_id) - if current_user == corpus.user or current_user.is_administrator(): - return f(*args, **kwargs) - abort(403) - return decorated_function - return decorator + +def corpus_owner_or_admin_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + corpus_id = kwargs.get('corpus_id') + corpus = Corpus.query.get_or_404(corpus_id) + if current_user == corpus.user or current_user.is_administrator(): + return f(*args, **kwargs) + abort(403) + return decorated_function diff --git a/app/corpora/routes.py b/app/corpora/routes.py index 27057407..a487e834 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -43,7 +43,7 @@ def fake_add(): @bp.route('//is_public', methods=['POST']) @login_required -@corpus_owner_or_admin_required() +@corpus_owner_or_admin_required def update_corpus_is_public(corpus_id): is_public = request.json if not isinstance(is_public, bool): @@ -58,7 +58,7 @@ def update_corpus_is_public(corpus_id): @bp.route('//followers/add', methods=['POST']) @login_required -@corpus_owner_or_admin_required() +@corpus_owner_or_admin_required def add_corpus_followers(corpus_id): usernames = request.json if not (isinstance(usernames, list) or all(isinstance(u, str) for u in usernames)): @@ -207,7 +207,7 @@ def generate_corpus_share_link(corpus_id): @bp.route('/', methods=['DELETE']) @login_required -@corpus_owner_or_admin_required() +@corpus_owner_or_admin_required def delete_corpus(corpus_id): def _delete_corpus(app, corpus_id): with app.app_context(): @@ -238,7 +238,7 @@ def analyse_corpus(corpus_id): @bp.route('//build', methods=['POST']) @login_required -@corpus_owner_or_admin_required() +@corpus_owner_or_admin_required def build_corpus(corpus_id): def _build_corpus(app, corpus_id): with app.app_context(): diff --git a/app/static/css/colors.scss b/app/static/css/colors.scss index f8f04228..0e43916a 100644 --- a/app/static/css/colors.scss +++ b/app/static/css/colors.scss @@ -22,6 +22,11 @@ $color: ( "surface": #ffffff, "error": #b00020 ), + "social-area": ( + "base": #d6ae86, + "darken": #C98536, + "lighten": #EAE2DB + ), "service": ( "corpus-analysis": ( "base": #aa9cc9, @@ -108,6 +113,16 @@ $color: ( } } +@each $key, $color-code in map-get($color, "social-area") { + .social-area-color-#{$key} { + background-color: $color-code !important; + } + + .social-area-color-border-#{$key} { + border-color: $color-code !important; + } +} + @each $service-name, $color-palette in map-get($color, "service") { .service-color[data-service="#{$service-name}"] { background-color: map-get($color-palette, "base") !important; diff --git a/app/static/js/ResourceLists/UserList.js b/app/static/js/ResourceLists/UserList.js index 65542de0..8d0e590f 100644 --- a/app/static/js/ResourceLists/UserList.js +++ b/app/static/js/ResourceLists/UserList.js @@ -20,7 +20,7 @@ class UserList extends ResourceList { - send + `.trim(); diff --git a/app/templates/_sidenav.html.j2 b/app/templates/_sidenav.html.j2 index 62dcc604..0027772e 100644 --- a/app/templates/_sidenav.html.j2 +++ b/app/templates/_sidenav.html.j2 @@ -1,6 +1,6 @@
    • -
      +
      @@ -26,7 +26,7 @@
    • IMy Corpora
    • JMy Jobs
    • new_labelContribute
    • -
    • groupSocial Area
    • +
    • Processes & Services
    • File setup
    • diff --git a/app/templates/main/social_area.html.j2 b/app/templates/main/social_area.html.j2 index fbe6974e..8aaeecfa 100644 --- a/app/templates/main/social_area.html.j2 +++ b/app/templates/main/social_area.html.j2 @@ -1,7 +1,7 @@ {% extends "base.html.j2" %} {% import "materialize/wtf.html.j2" as wtf %} -{% block main_attribs %} style="background-color:#d8c9ba86" {% endblock main_attribs %} +{% block main_attribs %} class="social-area-color-lighten" {% endblock main_attribs %} {% block page_content %}
      @@ -14,14 +14,14 @@

       

       

      - +
      -
      +
      - closeCancel + closeCancel {{ wtf.render_field(form.submit, material_icon='send') }}
      diff --git a/app/templates/corpora/public_corpora.html.j2 b/app/templates/corpora/public_corpora.html.j2 deleted file mode 100644 index 9be377f3..00000000 --- a/app/templates/corpora/public_corpora.html.j2 +++ /dev/null @@ -1,72 +0,0 @@ -{% extends "base.html.j2" %} - -{% block main_attribs %} class="service-scheme" data-service="corpus-analysis"{% endblock main_attribs %} - -{% block page_content %} -
      -
      -
      -
      -
      -
      -

      ICorpora

      -
      -
      -
      - search - - -
      -
      -
      -
      -
      - - -
      -
      -
      -
      -
      - - - - - - - - - - -
      Title and DescriptionStatus
      -
        -
        -
        -
        -
        -
        -
        -{% endblock page_content %} - - -{% block scripts %} -{{ super() }} - -{% endblock scripts %} diff --git a/app/templates/corpora/public_corpus.html.j2 b/app/templates/corpora/public_corpus.html.j2 index 41f5798b..ff445e38 100644 --- a/app/templates/corpora/public_corpus.html.j2 +++ b/app/templates/corpora/public_corpus.html.j2 @@ -101,30 +101,3 @@
      {% endblock page_content %} - -{% block scripts %} -{{ super() }} - -{% endblock scripts %} diff --git a/app/templates/corpora/public_corpus.js.j2 b/app/templates/corpora/public_corpus.js.j2 new file mode 100644 index 00000000..0b8205f4 --- /dev/null +++ b/app/templates/corpora/public_corpus.js.j2 @@ -0,0 +1,11 @@ +let corpusId = {{ corpus.hashid|tojson }}; +let corpusFileList = new PublicCorpusFileList(document.querySelector('.corpus-file-list')); +corpusFileList.add({{ corpus_files|tojson }}); + +let unfollowRequestElement = document.querySelector('.action-button[data-action="unfollow-request"]'); +unfollowRequestElement.addEventListener('click', () => { + Requests.corpora.entity.followers.entity.delete(corpusId, currentUserId) + .then((response) => { + window.location.href = {{ url_for('main.dashboard')|tojson }}; + }); +}); diff --git a/app/templates/services/spacy_nlp_pipeline.html.j2 b/app/templates/services/spacy_nlp_pipeline.html.j2 index 030ea163..8f466e3d 100644 --- a/app/templates/services/spacy_nlp_pipeline.html.j2 +++ b/app/templates/services/spacy_nlp_pipeline.html.j2 @@ -77,7 +77,7 @@ {{ form.model.label }} help_outline - new_label + new_label
      diff --git a/app/templates/services/tesseract_ocr_pipeline.html.j2 b/app/templates/services/tesseract_ocr_pipeline.html.j2 index ff4fd38b..11d65c64 100644 --- a/app/templates/services/tesseract_ocr_pipeline.html.j2 +++ b/app/templates/services/tesseract_ocr_pipeline.html.j2 @@ -59,7 +59,7 @@ {{ form.model.label }} help_outline - new_label + new_label {% for error in form.model.errors %} {{ error }} From c589fd1f781309caa7fc51ba07ac3313160e1825 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Fri, 10 Mar 2023 10:34:46 +0100 Subject: [PATCH 050/177] Remove unused function in utils --- app/static/js/Utils.js | 56 ------------------------------------------ 1 file changed, 56 deletions(-) diff --git a/app/static/js/Utils.js b/app/static/js/Utils.js index 12bdea0d..822ab775 100644 --- a/app/static/js/Utils.js +++ b/app/static/js/Utils.js @@ -69,62 +69,6 @@ class Utils { return Utils.mergeObjectsDeep(mergedObject, ...objects.slice(2)); } - static deleteCorpusFileRequest(userId, corpusId, corpusFileId) { - return new Promise((resolve, reject) => { - let corpusFile; - try { - corpusFile = app.data.users[userId].corpora[corpusId].files[corpusFileId]; - } catch (error) { - corpusFile = {}; - } - - 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 corpusFileTitle = corpusFile?.title; - fetch(`/corpora/${corpusId}/files/${corpusFileId}`, {method: 'DELETE', 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 File "${corpusFileTitle}" deleted`, 'corpus'); - resolve(response); - }, - (response) => { - app.flash('Something went wrong', 'error'); - reject(response); - } - ); - }); - modal.open(); - }); - } - static deleteProfileAvatarRequest(userId) { return new Promise((resolve, reject) => { let modalElement = Utils.HTMLToElement( From 2529dfeb6264172860f20fd78ee6dd2067a19237 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Fri, 10 Mar 2023 12:07:18 +0100 Subject: [PATCH 051/177] remove testroute --- app/main/routes.py | 7 +- app/templates/main/dashboard2.html.j2 | 274 -------------------------- 2 files changed, 1 insertion(+), 280 deletions(-) delete mode 100644 app/templates/main/dashboard2.html.j2 diff --git a/app/main/routes.py b/app/main/routes.py index 287a04f5..918f7414 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -30,12 +30,6 @@ def dashboard(): return render_template('main/dashboard.html.j2', title='Dashboard') -@bp.route('/dashboard2') -@login_required -def dashboard2(): - return render_template('main/dashboard2.html.j2', title='Dashboard') - - @bp.route('/user_manual') def user_manual(): return render_template('main/user_manual.html.j2', title='User manual') @@ -55,6 +49,7 @@ def privacy_policy(): def terms_of_use(): return render_template('main/terms_of_use.html.j2', title='Terms of Use') + @bp.route('/social-area') def social_area(): users = [ diff --git a/app/templates/main/dashboard2.html.j2 b/app/templates/main/dashboard2.html.j2 deleted file mode 100644 index d45aaa04..00000000 --- a/app/templates/main/dashboard2.html.j2 +++ /dev/null @@ -1,274 +0,0 @@ -{% extends "base.html.j2" %} -{% from "main/_breadcrumbs.html.j2" import breadcrumbs with context %} - -{% block page_content %} -
      -
      -
      -
      -

      Dashboard

      -
      -
      -
      -

      Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.

      -
      - -
      -
      - - -
      -
      -
      -
      -
      -
      -
      - search - - -
      -
      -
      -
      -
      - -
      -
      -
      -
      -

      My Corpora

      -

      Create a corpus to interactively perform linguistic analysis.

      -

      Or browse our users public corpora.

      -
      -
      -
      -
      -
      - - - - - - - - - - -
      Title and DescriptionStatus
      -
        -
        -
        - -
        -
        -
        - -
        -
        -
        - -
        -
        -
        -
        -
        -
        -
        - search - - -
        -
        -
        -
        -
        - -
        -
        -
        -
        -

        My Jobs

        -

        - A job is the execution of a service provided by nopaque. You can - create any number of jobs and let them be processed simultaneously. We - strongly recommend that you create a folder on your computer where you - save the various files that nopaque provides you with after each - pre-processing step. You will need the result of each step for the - next step. -

        -

        Where is my Job data? Don't worry, please read this news entry

        -
        -
        -
        -
        -
        - - - - - - - - - - -
        Title and DescriptionStatus
        -
          -
          -
          - -
          -
          -
          - -
          -
          -
          - -
          -
          -
          -
          -
          -
          -
          - search - - -
          -
          -
          -
          -
          - -
          -
          -
          -
          -

          My Groups

          -

          Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.

          -
          -
          -
          -
          -
          - - - - - - - - - - -
          Title and DescriptionStatus
          -
            -
            -
            - -
            -
            -
            - -
            -
            -
            -{% endblock page_content %} - -{% block modals %} -{{ super() }} - -{% endblock modals %} - - -{% block scripts %} -{{ super() }} - -{% endblock scripts %} From 6ba3f9c849e997ce14fa7ce88975a00181fcf9a5 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Fri, 10 Mar 2023 12:56:09 +0100 Subject: [PATCH 052/177] Add settings to project vscode settings.yml --- .vscode/settings.json | 23 ++++++++++++++++++++++- app/static/js/Requests/Corpora.js | 0 2 files changed, 22 insertions(+), 1 deletion(-) delete mode 100644 app/static/js/Requests/Corpora.js diff --git a/.vscode/settings.json b/.vscode/settings.json index 0967ef42..c2a8567e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1 +1,22 @@ -{} +{ + "editor.rulers": [79], + "files.insertFinalNewline": true, + "[css]": { + "editor.tabSize": 2 + }, + "[scss]": { + "editor.tabSize": 2 + }, + "[html]": { + "editor.tabSize": 2 + }, + "[javascript]": { + "editor.tabSize": 2 + }, + "[jinja-html]": { + "editor.tabSize": 2 + }, + "[jinja-js]": { + "editor.tabSize": 2 + }, +} diff --git a/app/static/js/Requests/Corpora.js b/app/static/js/Requests/Corpora.js deleted file mode 100644 index e69de29b..00000000 From ecb577628be53a21b72269304cc6547859091cf3 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Fri, 10 Mar 2023 14:55:10 +0100 Subject: [PATCH 053/177] Remove debug comments --- app/contributions/tesseract_ocr_pipeline_models/json_routes.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/contributions/tesseract_ocr_pipeline_models/json_routes.py b/app/contributions/tesseract_ocr_pipeline_models/json_routes.py index 705fbdb4..f4c4ca79 100644 --- a/app/contributions/tesseract_ocr_pipeline_models/json_routes.py +++ b/app/contributions/tesseract_ocr_pipeline_models/json_routes.py @@ -39,8 +39,6 @@ def delete_tesseract_model(tesseract_ocr_pipeline_model_id): @permission_required('CONTRIBUTE') @content_negotiation(consumes='application/json', produces='application/json') def update_tesseract_ocr_pipeline_model_is_public(tesseract_ocr_pipeline_model_id): - # body: jsonify({'is_public': True}) - # body: jsonify(False) is_public = request.json if not isinstance(is_public, bool): abort(400) From b6f155a06ba8d0914138fe27f7f5fde60d42adcd Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Fri, 10 Mar 2023 15:17:24 +0100 Subject: [PATCH 054/177] Fix error_handler --- app/__init__.py | 6 +++--- app/errors/__init__.py | 7 ++++--- app/errors/handlers.py | 9 ++------- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index cc747a89..dcb58e47 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -54,6 +54,9 @@ def create_app(config: Config = Config) -> Flask: scheduler.init_app(app) socketio.init_app(app, message_queue=app.config['NOPAQUE_SOCKETIO_MESSAGE_QUEUE_URI']) # noqa + from .errors import init_app as init_error_handlers + init_error_handlers(app) + from .admin import bp as admin_blueprint app.register_blueprint(admin_blueprint, url_prefix='/admin') @@ -69,9 +72,6 @@ def create_app(config: Config = Config) -> Flask: from .corpora import bp as corpora_blueprint app.register_blueprint(corpora_blueprint, url_prefix='/corpora') - from .errors import bp as errors_blueprint - app.register_blueprint(errors_blueprint) - from .jobs import bp as jobs_blueprint app.register_blueprint(jobs_blueprint, url_prefix='/jobs') diff --git a/app/errors/__init__.py b/app/errors/__init__.py index 0d79af48..1f0480b4 100644 --- a/app/errors/__init__.py +++ b/app/errors/__init__.py @@ -1,5 +1,6 @@ -from flask import Blueprint +from werkzeug.exceptions import HTTPException +from .handlers import generic_error_handler -bp = Blueprint('errors', __name__) -from . import handlers +def init_app(app): + app.register_error_handler(HTTPException, generic_error_handler) diff --git a/app/errors/handlers.py b/app/errors/handlers.py index cc6c9268..5a6c413d 100644 --- a/app/errors/handlers.py +++ b/app/errors/handlers.py @@ -1,11 +1,6 @@ -from flask import render_template, request -from werkzeug.exceptions import HTTPException -from . import bp +from flask import render_template -@bp.errorhandler(HTTPException) def generic_error_handler(e): - if (request.accept_mimetypes.accept_json - and not request.accept_mimetypes.accept_html): - return {'errors': {'message': e.description}}, e.code + print('test') return render_template('errors/error.html.j2', error=e), e.code From bda18e64c85f12624327daa03851ffad61c03410 Mon Sep 17 00:00:00 2001 From: Inga Kirschnick Date: Fri, 10 Mar 2023 17:03:37 +0100 Subject: [PATCH 055/177] Small Dashboard Job List fix --- app/static/js/ResourceLists/JobList.js | 2 +- app/templates/main/dashboard.html.j2 | 28 -------------------------- 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/app/static/js/ResourceLists/JobList.js b/app/static/js/ResourceLists/JobList.js index ff7f82b2..2c48996e 100644 --- a/app/static/js/ResourceLists/JobList.js +++ b/app/static/js/ResourceLists/JobList.js @@ -96,7 +96,7 @@ class JobList extends ResourceList { let listAction = listActionElement === null ? 'view' : listActionElement.dataset.listAction; switch (listAction) { case 'delete-request': { - Utils.deleteJobRequest(this.userId, itemId); + Requests.jobs.entity.delete(itemId); break; } case 'view': { diff --git a/app/templates/main/dashboard.html.j2 b/app/templates/main/dashboard.html.j2 index ca5422df..5474db97 100644 --- a/app/templates/main/dashboard.html.j2 +++ b/app/templates/main/dashboard.html.j2 @@ -42,23 +42,6 @@ - {#
            -

            Social

            -
            -
            - Other users -

            Find other users and follow them to see their corpora.

            -
            -
            -
            -
            -
            - Public corpora -

            Find public corpora

            -
            -
            -
            -
            #} {% endblock page_content %} @@ -113,14 +96,3 @@ {% endblock modals %} - -{% block scripts %} -{{ super() }} -{# #} -{% endblock scripts %} - From a1af3e34d26fc12351f3297746cd62f42e82b333 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Mon, 13 Mar 2023 07:58:33 +0100 Subject: [PATCH 056/177] Remove unused print statement --- app/errors/handlers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/app/errors/handlers.py b/app/errors/handlers.py index 5a6c413d..8c2e4fcf 100644 --- a/app/errors/handlers.py +++ b/app/errors/handlers.py @@ -2,5 +2,4 @@ from flask import render_template def generic_error_handler(e): - print('test') return render_template('errors/error.html.j2', error=e), e.code From 5c2225c43eda3b94f611e97664f0c6508b910363 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Mon, 13 Mar 2023 08:20:09 +0100 Subject: [PATCH 057/177] Let the generic error handler generate json again --- app/errors/handlers.py | 13 ++++++++++--- app/templates/errors/error.html.j2 | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/errors/handlers.py b/app/errors/handlers.py index 8c2e4fcf..bf2fef09 100644 --- a/app/errors/handlers.py +++ b/app/errors/handlers.py @@ -1,5 +1,12 @@ -from flask import render_template +from flask import jsonify, render_template, request, Response +from werkzeug.exceptions import HTTPException -def generic_error_handler(e): - return render_template('errors/error.html.j2', error=e), e.code +def generic_error_handler(error: HTTPException): + accent_json: bool = request.accept_mimetypes.accept_json + accept_html: bool = request.accept_mimetypes.accept_html + if accent_json and not accept_html: + response: Response = jsonify(str(error)) + response.status_code = error.code + return response + return render_template('errors/error.html.j2', error=error), error.code diff --git a/app/templates/errors/error.html.j2 b/app/templates/errors/error.html.j2 index ef19de5f..8db723d2 100644 --- a/app/templates/errors/error.html.j2 +++ b/app/templates/errors/error.html.j2 @@ -4,7 +4,7 @@ {% block page_content %}
            -

            {{ error.name }}

            +

            {{ error.code }} {{ error.name }}

            {{ error.description }}

            {% endblock page_content %} From ca53974e504680f59a19a90b14fcb338a7772b12 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Mon, 13 Mar 2023 08:36:51 +0100 Subject: [PATCH 058/177] Update the generic error handling again. Added type hints --- app/errors/__init__.py | 4 ++-- app/errors/handlers.py | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/errors/__init__.py b/app/errors/__init__.py index 1f0480b4..847658fb 100644 --- a/app/errors/__init__.py +++ b/app/errors/__init__.py @@ -1,6 +1,6 @@ from werkzeug.exceptions import HTTPException -from .handlers import generic_error_handler +from .handlers import generic def init_app(app): - app.register_error_handler(HTTPException, generic_error_handler) + app.register_error_handler(HTTPException, generic) diff --git a/app/errors/handlers.py b/app/errors/handlers.py index bf2fef09..fe7aaf4f 100644 --- a/app/errors/handlers.py +++ b/app/errors/handlers.py @@ -1,12 +1,13 @@ from flask import jsonify, render_template, request, Response from werkzeug.exceptions import HTTPException +from typing import Tuple, Union -def generic_error_handler(error: HTTPException): +def generic(error: HTTPException) -> Tuple[Union[str, Response], int]: + ''' Generic error handler ''' accent_json: bool = request.accept_mimetypes.accept_json accept_html: bool = request.accept_mimetypes.accept_html if accent_json and not accept_html: response: Response = jsonify(str(error)) - response.status_code = error.code - return response + return response, error.code return render_template('errors/error.html.j2', error=error), error.code From e03b5258efddef3aae370798a258e24478cae60e Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Mon, 13 Mar 2023 09:49:59 +0100 Subject: [PATCH 059/177] Codestyle enhancements --- .../spacy_nlp_pipeline_models/__init__.py | 2 +- .../spacy_nlp_pipeline_models/json_routes.py | 10 +--- .../spacy_nlp_pipeline_models/routes.py | 55 ++++++++----------- .../tesseract_ocr_pipeline_models/__init__.py | 2 +- .../json_routes.py | 10 +--- .../tesseract_ocr_pipeline_models/routes.py | 45 +++++++-------- app/static/js/Forms/Form.js | 4 +- 7 files changed, 52 insertions(+), 76 deletions(-) diff --git a/app/contributions/spacy_nlp_pipeline_models/__init__.py b/app/contributions/spacy_nlp_pipeline_models/__init__.py index 6b73681a..8ff119d0 100644 --- a/app/contributions/spacy_nlp_pipeline_models/__init__.py +++ b/app/contributions/spacy_nlp_pipeline_models/__init__.py @@ -1,7 +1,7 @@ from flask import Blueprint -TEMPLATE_FOLDER = 'contributions/spacy_nlp_pipeline_models' +template_base_dir = 'contributions/spacy_nlp_pipeline_models' bp = Blueprint('spacy_nlp_pipeline_models', __name__) diff --git a/app/contributions/spacy_nlp_pipeline_models/json_routes.py b/app/contributions/spacy_nlp_pipeline_models/json_routes.py index 9247f85b..f7a9a254 100644 --- a/app/contributions/spacy_nlp_pipeline_models/json_routes.py +++ b/app/contributions/spacy_nlp_pipeline_models/json_routes.py @@ -1,4 +1,4 @@ -from flask import abort, current_app, jsonify, request +from flask import abort, current_app, request from flask_login import login_required, current_user from threading import Thread from app import db @@ -29,9 +29,7 @@ def delete_spacy_model(spacy_nlp_pipeline_model_id): 'message': \ f'SpaCy NLP Pipeline Model "{snpm.title}" marked for deletion' } - response = jsonify(resonse_data) - response.status_code = 202 - return response + return resonse_data, 202 @bp.route('//is_public', methods=['PUT']) @@ -53,6 +51,4 @@ def update_spacy_nlp_pipeline_model_is_public(spacy_nlp_pipeline_model_id): f' is now {"public" if is_public else "private"}' ) } - response = jsonify(response_data) - response.status_code = 200 - return response + return response_data, 200 diff --git a/app/contributions/spacy_nlp_pipeline_models/routes.py b/app/contributions/spacy_nlp_pipeline_models/routes.py index 2e416c47..609941fc 100644 --- a/app/contributions/spacy_nlp_pipeline_models/routes.py +++ b/app/contributions/spacy_nlp_pipeline_models/routes.py @@ -1,8 +1,8 @@ -from flask import abort, flash, Markup, redirect, render_template, url_for -from flask_login import login_required, current_user +from flask import abort, flash, redirect, render_template, url_for +from flask_login import current_user, login_required from app import db from app.models import SpaCyNLPPipelineModel -from . import bp, TEMPLATE_FOLDER +from . import bp, template_base_dir from .forms import ( CreateSpaCyNLPPipelineModelForm, EditSpaCyNLPPipelineModelForm @@ -13,21 +13,21 @@ from .forms import ( @login_required def spacy_nlp_pipeline_models(): return render_template( - f'{TEMPLATE_FOLDER}/spacy_nlp_pipeline_models.html.j2', + f'{template_base_dir}/spacy_nlp_pipeline_models.html.j2', title='SpaCy NLP Pipeline Models' ) -@bp.route('/create', methods=['GET', 'POST']) +@bp.route('/create') @login_required def create_spacy_nlp_pipeline_model(): - form = CreateSpaCyNLPPipelineModelForm(prefix='create-spacy-nlp-pipeline-model-form') + form_prefix = 'create-spacy-nlp-pipeline-model-form' + form = CreateSpaCyNLPPipelineModelForm(prefix=form_prefix) if form.is_submitted(): if not form.validate(): - response = {'errors': form.errors} - return response, 400 + return {'errors': form.errors}, 400 try: - spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.create( + snpm = SpaCyNLPPipelineModel.create( form.spacy_model_file.data, compatible_service_versions=form.compatible_service_versions.data, description=form.description.data, @@ -44,18 +44,10 @@ def create_spacy_nlp_pipeline_model(): except OSError: abort(500) db.session.commit() - spacy_nlp_pipeline_model_url = url_for( - '.spacy_nlp_pipeline_model', - spacy_nlp_pipeline_model_id=spacy_nlp_pipeline_model.id - ) - message = Markup( - f'SpaCy NLP Pipeline model "{spacy_nlp_pipeline_model.title}" ' - 'created' - ) - flash(message) - return '', 201, {'Location': spacy_nlp_pipeline_model_url} + flash(f'SpaCy NLP Pipeline model "{snpm.title}" created') + return {}, 201, {'Location': url_for('.spacy_nlp_pipeline_models')} return render_template( - f'{TEMPLATE_FOLDER}/create_spacy_nlp_pipeline_model.html.j2', + f'{template_base_dir}/create_spacy_nlp_pipeline_model.html.j2', form=form, title='Create SpaCy NLP Pipeline Model' ) @@ -64,24 +56,21 @@ def create_spacy_nlp_pipeline_model(): @bp.route('/', methods=['GET', 'POST']) @login_required def spacy_nlp_pipeline_model(spacy_nlp_pipeline_model_id): - spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id) + snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id) + form_prefix = 'edit-spacy-nlp-pipeline-model-form' form = EditSpaCyNLPPipelineModelForm( - data=spacy_nlp_pipeline_model.to_json_serializeable(), - prefix='edit-spacy-nlp-pipeline-model-form' + data=snpm.to_json_serializeable(), + prefix=form_prefix ) if form.validate_on_submit(): - form.populate_obj(spacy_nlp_pipeline_model) - if db.session.is_modified(spacy_nlp_pipeline_model): - message = Markup( - f'SpaCy NLP Pipeline model "{spacy_nlp_pipeline_model.title}" ' - 'updated' - ) - flash(message) + form.populate_obj(snpm) + if db.session.is_modified(snpm): + flash(f'SpaCy NLP Pipeline model "{snpm.title}" updated') db.session.commit() return redirect(url_for('.spacy_nlp_pipeline_models')) return render_template( - f'{TEMPLATE_FOLDER}/spacy_nlp_pipeline_model.html.j2', + f'{template_base_dir}/spacy_nlp_pipeline_model.html.j2', form=form, - spacy_nlp_pipeline_model=spacy_nlp_pipeline_model, - title=f'{spacy_nlp_pipeline_model.title} {spacy_nlp_pipeline_model.version}' + spacy_nlp_pipeline_model=snpm, + title=f'{snpm.title} {snpm.version}' ) diff --git a/app/contributions/tesseract_ocr_pipeline_models/__init__.py b/app/contributions/tesseract_ocr_pipeline_models/__init__.py index c60e0915..cf44126d 100644 --- a/app/contributions/tesseract_ocr_pipeline_models/__init__.py +++ b/app/contributions/tesseract_ocr_pipeline_models/__init__.py @@ -1,7 +1,7 @@ from flask import Blueprint -TEMPLATE_FOLDER = 'contributions/tesseract_ocr_pipeline_models' +template_base_dir = 'contributions/tesseract_ocr_pipeline_models' bp = Blueprint('tesseract_ocr_pipeline_models', __name__) diff --git a/app/contributions/tesseract_ocr_pipeline_models/json_routes.py b/app/contributions/tesseract_ocr_pipeline_models/json_routes.py index f4c4ca79..81aa6598 100644 --- a/app/contributions/tesseract_ocr_pipeline_models/json_routes.py +++ b/app/contributions/tesseract_ocr_pipeline_models/json_routes.py @@ -1,4 +1,4 @@ -from flask import abort, current_app, jsonify, request +from flask import abort, current_app, request from flask_login import login_required, current_user from threading import Thread from app import db @@ -29,9 +29,7 @@ def delete_tesseract_model(tesseract_ocr_pipeline_model_id): 'message': \ f'Tesseract OCR Pipeline Model "{topm.title}" marked for deletion' } - response = jsonify(response_data) - response.status_code = 202 - return response + return response_data, 202 @bp.route('//is_public', methods=['PUT']) @@ -53,6 +51,4 @@ def update_tesseract_ocr_pipeline_model_is_public(tesseract_ocr_pipeline_model_i f' is now {"public" if is_public else "private"}' ) } - response = jsonify(response_data) - response.status_code = 200 - return response + return response_data, 200 diff --git a/app/contributions/tesseract_ocr_pipeline_models/routes.py b/app/contributions/tesseract_ocr_pipeline_models/routes.py index 823e54d9..b888cef7 100644 --- a/app/contributions/tesseract_ocr_pipeline_models/routes.py +++ b/app/contributions/tesseract_ocr_pipeline_models/routes.py @@ -1,8 +1,8 @@ -from flask import abort, flash, Markup, redirect, render_template, url_for +from flask import abort, flash, redirect, render_template, url_for from flask_login import login_required, current_user from app import db from app.models import TesseractOCRPipelineModel -from . import bp, TEMPLATE_FOLDER +from . import bp, template_base_dir from .forms import ( CreateTesseractOCRPipelineModelForm, EditTesseractOCRPipelineModelForm @@ -13,7 +13,7 @@ from .forms import ( @login_required def tesseract_ocr_pipeline_models(): return render_template( - f'{TEMPLATE_FOLDER}/tesseract_ocr_pipeline_models.html.j2', + f'{template_base_dir}/tesseract_ocr_pipeline_models.html.j2', title='Tesseract OCR Pipeline Models' ) @@ -21,13 +21,13 @@ def tesseract_ocr_pipeline_models(): @bp.route('/create', methods=['GET', 'POST']) @login_required def create_tesseract_ocr_pipeline_model(): - form = CreateTesseractOCRPipelineModelForm(prefix='create-tesseract-ocr-pipeline-model-form') + form_prefix = 'create-tesseract-ocr-pipeline-model-form' + form = CreateTesseractOCRPipelineModelForm(prefix=form_prefix) if form.is_submitted(): if not form.validate(): - response = {'errors': form.errors} - return response, 400 + return {'errors': form.errors}, 400 try: - tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.create( + topm = TesseractOCRPipelineModel.create( form.tesseract_model_file.data, compatible_service_versions=form.compatible_service_versions.data, description=form.description.data, @@ -43,15 +43,10 @@ def create_tesseract_ocr_pipeline_model(): except OSError: abort(500) db.session.commit() - tesseract_ocr_pipeline_model_url = url_for( - '.tesseract_ocr_pipeline_model', - tesseract_ocr_pipeline_model_id=tesseract_ocr_pipeline_model.id - ) - message = Markup(f'Tesseract OCR Pipeline model "{tesseract_ocr_pipeline_model.title}" created') - flash(message) - return {}, 201, {'Location': tesseract_ocr_pipeline_model_url} + flash(f'Tesseract OCR Pipeline model "{topm.title}" created') + return {}, 201, {'Location': url_for('.tesseract_ocr_pipeline_models')} return render_template( - f'{TEMPLATE_FOLDER}/create_tesseract_ocr_pipeline_model.html.j2', + f'{template_base_dir}/create_tesseract_ocr_pipeline_model.html.j2', form=form, title='Create Tesseract OCR Pipeline Model' ) @@ -60,21 +55,21 @@ def create_tesseract_ocr_pipeline_model(): @bp.route('/', methods=['GET', 'POST']) @login_required def tesseract_ocr_pipeline_model(tesseract_ocr_pipeline_model_id): - tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id) + topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id) + form_prefix = 'edit-tesseract-ocr-pipeline-model-form' form = EditTesseractOCRPipelineModelForm( - data=tesseract_ocr_pipeline_model.to_json_serializeable(), - prefix='edit-tesseract-ocr-pipeline-model-form' + data=topm.to_json_serializeable(), + prefix=form_prefix ) if form.validate_on_submit(): - form.populate_obj(tesseract_ocr_pipeline_model) - if db.session.is_modified(tesseract_ocr_pipeline_model): - message = Markup(f'Tesseract OCR Pipeline model "{tesseract_ocr_pipeline_model.title}" updated') - flash(message) + form.populate_obj(topm) + if db.session.is_modified(topm): + flash(f'Tesseract OCR Pipeline model "{topm.title}" updated') db.session.commit() return redirect(url_for('.tesseract_ocr_pipeline_models')) return render_template( - f'{TEMPLATE_FOLDER}/tesseract_ocr_pipeline_model.html.j2', + f'{template_base_dir}/tesseract_ocr_pipeline_model.html.j2', form=form, - tesseract_ocr_pipeline_model=tesseract_ocr_pipeline_model, - title=f'{tesseract_ocr_pipeline_model.title} {tesseract_ocr_pipeline_model.version}' + tesseract_ocr_pipeline_model=topm, + title=f'{topm.title} {topm.version}' ) diff --git a/app/static/js/Forms/Form.js b/app/static/js/Forms/Form.js index a9604c69..c3496e18 100644 --- a/app/static/js/Forms/Form.js +++ b/app/static/js/Forms/Form.js @@ -92,7 +92,6 @@ class Form { } if (request.status === 400) { let responseJson = JSON.parse(request.responseText); - console.log(responseJson); for (let [inputName, inputErrors] of Object.entries(responseJson.errors)) { let inputFieldElement = this.formElement .querySelector(`input[name$="${inputName}"], select[name$="${inputName}"]`) @@ -122,10 +121,11 @@ class Form { request.setRequestHeader('Accept', 'application/json'); let formData = new FormData(this.formElement); switch (this.formElement.enctype) { - case 'application/x-www-form-urlencoded': + case 'application/x-www-form-urlencoded': { let urlSearchParams = new URLSearchParams(formData); request.send(urlSearchParams); break; + } case 'multipart/form-data': { request.send(formData); break; From f348d1ed237ce2989bda6d54dc22e839b012aeca Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Mon, 13 Mar 2023 11:24:01 +0100 Subject: [PATCH 060/177] Add missing method to route --- app/contributions/spacy_nlp_pipeline_models/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/contributions/spacy_nlp_pipeline_models/routes.py b/app/contributions/spacy_nlp_pipeline_models/routes.py index 609941fc..fd972902 100644 --- a/app/contributions/spacy_nlp_pipeline_models/routes.py +++ b/app/contributions/spacy_nlp_pipeline_models/routes.py @@ -18,7 +18,7 @@ def spacy_nlp_pipeline_models(): ) -@bp.route('/create') +@bp.route('/create', methods=['GET', 'POST']) @login_required def create_spacy_nlp_pipeline_model(): form_prefix = 'create-spacy-nlp-pipeline-model-form' From eec056e0102e4f4ae8644828a383dc8bc5ec4eee Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Mon, 13 Mar 2023 12:05:35 +0100 Subject: [PATCH 061/177] Change template base variable name --- app/corpora/files/__init__.py | 2 +- app/corpora/files/routes.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/corpora/files/__init__.py b/app/corpora/files/__init__.py index 2f47a450..a52ff995 100644 --- a/app/corpora/files/__init__.py +++ b/app/corpora/files/__init__.py @@ -1,7 +1,7 @@ from flask import Blueprint -TEMPLATE_FOLDER = 'corpora/files' +template_base_dir = 'corpora/files' bp = Blueprint('files', __name__) diff --git a/app/corpora/files/routes.py b/app/corpora/files/routes.py index 7715f417..5ef4e0d0 100644 --- a/app/corpora/files/routes.py +++ b/app/corpora/files/routes.py @@ -11,7 +11,7 @@ import os from app import db from app.models import Corpus, CorpusFile, CorpusStatus from ..decorators import corpus_follower_permission_required -from . import bp, TEMPLATE_FOLDER +from . import bp, template_base_dir from .forms import CreateCorpusFileForm, UpdateCorpusFileForm @@ -58,7 +58,7 @@ def create_corpus_file(corpus_id): flash(f'Corpus File "{corpus_file.filename}" added', category='corpus') return '', 201, {'Location': corpus.url} return render_template( - f'{TEMPLATE_FOLDER}/create_corpus_file.html.j2', + f'{template_base_dir}/create_corpus_file.html.j2', corpus=corpus, form=form, title='Add corpus file' @@ -79,7 +79,7 @@ def corpus_file(corpus_id, corpus_file_id): flash(f'Corpus file "{corpus_file.filename}" updated', category='corpus') return redirect(corpus_file.corpus.url) return render_template( - f'{TEMPLATE_FOLDER}/corpus_file.html.j2', + f'{template_base_dir}/corpus_file.html.j2', corpus=corpus_file.corpus, corpus_file=corpus_file, form=form, From 646f735ab23653557f985b9d676ea8c6f382326a Mon Sep 17 00:00:00 2001 From: Inga Kirschnick Date: Mon, 13 Mar 2023 13:29:01 +0100 Subject: [PATCH 062/177] Reviewed User package and invite user optical fix --- app/corpora/routes.py | 3 + app/jobs/json_routes.py | 22 +-- app/static/css/materialize/fixes.css | 5 + app/static/js/Requests/users/users.js | 23 +++ app/static/js/Utils.js | 236 ----------------------- app/templates/_scripts.html.j2 | 3 +- app/templates/_sidenav.html.j2 | 2 +- app/templates/corpora/corpus.js.j2 | 13 +- app/templates/settings/settings.html.j2 | 20 +- app/templates/users/edit_profile.html.j2 | 55 ++---- app/templates/users/edit_profile.js.j2 | 36 ++++ app/templates/users/profile.html.j2 | 25 --- app/templates/users/profile.js.j2 | 19 ++ app/users/__init__.py | 3 +- app/users/json_routes.py | 54 ++++++ app/users/routes.py | 35 ---- 16 files changed, 190 insertions(+), 364 deletions(-) create mode 100644 app/static/js/Requests/users/users.js create mode 100644 app/templates/users/edit_profile.js.j2 create mode 100644 app/templates/users/profile.js.j2 create mode 100644 app/users/json_routes.py diff --git a/app/corpora/routes.py b/app/corpora/routes.py index 5484fb50..322c3657 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -13,6 +13,7 @@ from app.models import ( Corpus, CorpusFollowerAssociation, CorpusFollowerRole, + User ) from . import bp from .forms import CreateCorpusForm @@ -46,12 +47,14 @@ def create_corpus(): def corpus(corpus_id): corpus = Corpus.query.get_or_404(corpus_id) corpus_follower_roles = CorpusFollowerRole.query.all() + users = [u.to_json_serializeable() for u in User.query.filter(User.is_public == True, User.id != current_user.id).all()] # TODO: Add URL query option to toggle view if corpus.user == current_user or current_user.is_administrator(): return render_template( 'corpora/corpus.html.j2', corpus=corpus, corpus_follower_roles=corpus_follower_roles, + users = users, title='Corpus' ) if current_user.is_following_corpus(corpus) or corpus.is_public: diff --git a/app/jobs/json_routes.py b/app/jobs/json_routes.py index 50846bae..3562470f 100644 --- a/app/jobs/json_routes.py +++ b/app/jobs/json_routes.py @@ -1,4 +1,4 @@ -from flask import abort, current_app, jsonify +from flask import abort, current_app from flask_login import current_user, login_required from threading import Thread import os @@ -7,6 +7,7 @@ from app.decorators import admin_required, content_negotiation from app.models import Job, JobStatus from . import bp + @bp.route('/', methods=['DELETE']) @login_required @content_negotiation(produces='application/json') @@ -26,12 +27,10 @@ def delete_job(job_id): ) thread.start() response_data = { - 'message': \ - f'Job "{job.title}" marked for deletion' + 'message': f'Job "{job.title}" marked for deletion' } - response = jsonify(response_data) - response.status_code = 202 - return response + return response_data, 202 + @bp.route('//log') @login_required @@ -48,9 +47,7 @@ def job_log(job_id): 'message': '', 'jobLog': log } - response = jsonify(response_data) - response.status_code = 200 - return response + return response_data, 200 @bp.route('//restart', methods=['POST']) @@ -75,9 +72,6 @@ def restart_job(job_id): ) thread.start() response_data = { - 'message': \ - f'Job "{job.title}" marked for restarting' + 'message': f'Job "{job.title}" marked for restarting' } - response = jsonify(response_data) - response.status_code = 202 - return response + return response_data, 202 diff --git a/app/static/css/materialize/fixes.css b/app/static/css/materialize/fixes.css index b44af75f..75003a1f 100644 --- a/app/static/css/materialize/fixes.css +++ b/app/static/css/materialize/fixes.css @@ -1,3 +1,8 @@ .parallax-container .parallax { z-index: 0; } + +.autocomplete-content { + width: 100% !important; + left: 0 !important; +} diff --git a/app/static/js/Requests/users/users.js b/app/static/js/Requests/users/users.js new file mode 100644 index 00000000..0ae1434e --- /dev/null +++ b/app/static/js/Requests/users/users.js @@ -0,0 +1,23 @@ +/***************************************************************************** +* Users * +* Fetch requests for /users routes * +*****************************************************************************/ +Requests.users = {}; + +Requests.users.entity = {}; + +Requests.users.entity.delete = (userId) => { + let input = `/users/${userId}`; + let init = { + method: 'DELETE' + }; + return Requests.JSONfetch(input, init); +} + +Requests.users.entity.deleteAvatar = (userId) => { + let input = `/users/${userId}/avatar`; + let init = { + method: 'DELETE' + }; + return Requests.JSONfetch(input, init); +} diff --git a/app/static/js/Utils.js b/app/static/js/Utils.js index 822ab775..79879c75 100644 --- a/app/static/js/Utils.js +++ b/app/static/js/Utils.js @@ -69,240 +69,4 @@ class Utils { return Utils.mergeObjectsDeep(mergedObject, ...objects.slice(2)); } - static deleteProfileAvatarRequest(userId) { - return new Promise((resolve, reject) => { - 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) => { - fetch(`/users/${userId}/avatar`, {method: 'DELETE', 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(`Avatar marked for deletion`); - resolve(response); - }, - (response) => { - app.flash('Something went wrong', 'error'); - reject(response); - } - ); - }); - modal.open(); - }); - } - - // static deleteJobRequest(userId, jobId) { - // return new Promise((resolve, reject) => { - // let job; - // try { - // job = app.data.users[userId].jobs[jobId]; - // } catch (error) { - // job = {}; - // } - - // let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]'); - // confirmElement.addEventListener('click', (event) => { - // let jobTitle = job?.title; - // fetch(`/jobs/${jobId}`, {method: 'DELETE', 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(`Job "${jobTitle}" marked for deletion`, 'job'); - // resolve(response); - // }, - // (response) => { - // app.flash('Something went wrong', 'error'); - // reject(response); - // } - // ); - // }); - // modal.open(); - // }); - // } - - // static getJobLogRequest(userId, jobId) { - // return new Promise((resolve, reject) => { - // fetch(`/jobs/${jobId}/log`, {method: 'GET', headers: {Accept: 'application/json, text/plain'}}) - // .then( - // (response) => { - // if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);} - // if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);} - // return response.text(); - // }, - // (response) => { - // app.flash('Something went wrong', 'error'); - // reject(response); - // } - // ) - // .then( - // (text) => { - // let modalElement = Utils.HTMLToElement( - // ` - // - // ` - // ); - // document.querySelector('#modals').appendChild(modalElement); - // let modal = M.Modal.init( - // modalElement, - // { - // onCloseEnd: () => { - // modal.destroy(); - // modalElement.remove(); - // } - // } - // ); - // modal.open(); - // resolve(text); - // } - // ); - // }); - // } - - // static restartJobRequest(userId, jobId) { - // return new Promise((resolve, reject) => { - // let job; - // try { - // job = app.data.users[userId].jobs[jobId]; - // } catch (error) { - // job = {}; - // } - - // 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 jobTitle = job?.title; - // fetch(`/jobs/${jobId}/restart`, {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);} - // if (response.status === 409) {app.flash('Conflict', 'error'); reject(response);} - // app.flash(`Job "${jobTitle}" restarted.`, 'job'); - // resolve(response); - // }, - // (response) => { - // app.flash('Something went wrong', 'error'); - // reject(response); - // } - // ); - // }); - // modal.open(); - // }); - // } - - static deleteUserRequest(userId) { - return new Promise((resolve, reject) => { - let user; - try { - user = app.data.users[userId]; - } catch (error) { - user = {}; - } - - 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 userName = user?.username; - fetch(`/users/${userId}`, {method: 'DELETE', 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(`User "${userName}" marked for deletion`); - resolve(response); - }, - (response) => { - app.flash('Something went wrong', 'error'); - reject(response); - } - ); - }); - modal.open(); - }); - } } diff --git a/app/templates/_scripts.html.j2 b/app/templates/_scripts.html.j2 index 6dc253b4..11398cb9 100644 --- a/app/templates/_scripts.html.j2 +++ b/app/templates/_scripts.html.j2 @@ -64,7 +64,8 @@ 'js/Requests/corpora/corpora.js', 'js/Requests/corpora/files.js', 'js/Requests/corpora/followers.js', - 'js/Requests/jobs/jobs.js' + 'js/Requests/jobs/jobs.js', + 'js/Requests/users/users.js' %} {%- endassets %} diff --git a/app/templates/_sidenav.html.j2 b/app/templates/_sidenav.html.j2 index 0027772e..2d55999a 100644 --- a/app/templates/_sidenav.html.j2 +++ b/app/templates/_sidenav.html.j2 @@ -39,7 +39,7 @@
          • Account
          • settingsGeneral Settings
          • -
          • contact_pageProfile settings
          • +
          • contact_pageProfile Settings
          • Log out
          • {% if current_user.can('ADMINISTRATE') or current_user.can('USE_API') %}
          • diff --git a/app/templates/corpora/corpus.js.j2 b/app/templates/corpora/corpus.js.j2 index 7e815cf3..016b0b6a 100644 --- a/app/templates/corpora/corpus.js.j2 +++ b/app/templates/corpora/corpus.js.j2 @@ -26,16 +26,17 @@ deleteModalDeleteButtonElement.addEventListener('click', (event) => { let inviteUserModalElement = document.querySelector('#invite-user-modal'); let inviteUserModalSearchElement = document.querySelector('#invite-user-modal-search'); let inviteUserModalInviteButtonElement = document.querySelector('#invite-user-modal-invite-button'); +const users = {}; + +for (let user of {{ users|tojson }}) { + users[user.username] = user.avatar ? `/users/${user.id}/avatar` : '/static/images/user_avatar.png'; +} let inviteUserModalSearch = M.Chips.init( inviteUserModalSearchElement, { autocompleteOptions: { - data: { - 'nopaque': '/users/3V8Aqpg74JvxOd9o/avatar', - 'pjentsch': '/users/3V8Aqpg74JvxOd9o/avatar', - 'pjentsch2': '/users/3V8Aqpg74JvxOd9o/avatar' - } + data: users }, limit: 3, onChipAdd: (a, chipElement) => { @@ -43,7 +44,7 @@ let inviteUserModalSearch = M.Chips.init( chipElement.firstElementChild.click(); } }, - placeholder: 'Enter a username', + placeholder: 'Enter username', secondaryPlaceholder: 'Add more users' } ); diff --git a/app/templates/settings/settings.html.j2 b/app/templates/settings/settings.html.j2 index e9cfb97b..9210d36f 100644 --- a/app/templates/settings/settings.html.j2 +++ b/app/templates/settings/settings.html.j2 @@ -51,7 +51,7 @@ @@ -59,12 +59,26 @@ {% endblock page_content %} +{% block modals%} + +{% endblock modals %} + {% block scripts %} {{ super() }} {% endblock scripts %} diff --git a/app/templates/users/edit_profile.html.j2 b/app/templates/users/edit_profile.html.j2 index 11b4b4d2..1b2ef3f9 100644 --- a/app/templates/users/edit_profile.html.j2 +++ b/app/templates/users/edit_profile.html.j2 @@ -79,7 +79,7 @@
            @@ -111,44 +111,15 @@
            {% endblock page_content %} -{% block scripts %} -{{ super() }} - -{% endblock scripts %} +{% block modals %} + +{% endblock modals %} diff --git a/app/templates/users/edit_profile.js.j2 b/app/templates/users/edit_profile.js.j2 new file mode 100644 index 00000000..a5058d85 --- /dev/null +++ b/app/templates/users/edit_profile.js.j2 @@ -0,0 +1,36 @@ +let publicProfile = document.querySelector('#public-profile'); +let disableButtons = document.querySelectorAll('[data-action="disable"]'); +let deleteButton = document.querySelector('#delete-avatar'); +let avatar = document.querySelector('#avatar'); +let avatarUpload = document.querySelector('#avatar-upload'); + +for (let disableButton of disableButtons) { + disableButton.disabled = !publicProfile.checked; +} + +publicProfile.addEventListener('change', () => { + if (publicProfile.checked) { + for (let disableButton of disableButtons) { + disableButton.disabled = false; + } + } else { + for (let disableButton of disableButtons) { + disableButton.checked = false; + disableButton.disabled = true; + } + } +}); + +avatarUpload.addEventListener('change', function() { + let file = this.files[0]; + avatar.src = URL.createObjectURL(file); +}); + +deleteButton.addEventListener('click', () => { + Requests.users.entity.deleteAvatar({{ user.hashid|tojson }}) + .then( + (response) => { + avatar.src = "{{ url_for('static', filename='images/user_avatar.png') }}"; + } + ); +}); diff --git a/app/templates/users/profile.html.j2 b/app/templates/users/profile.html.j2 index 4a9a4c13..e73a3b7a 100644 --- a/app/templates/users/profile.html.j2 +++ b/app/templates/users/profile.html.j2 @@ -111,28 +111,3 @@
            {% endblock page_content %} -{% block scripts %} -{{ super() }} - -{% endblock scripts %} - diff --git a/app/templates/users/profile.js.j2 b/app/templates/users/profile.js.j2 new file mode 100644 index 00000000..5f729f18 --- /dev/null +++ b/app/templates/users/profile.js.j2 @@ -0,0 +1,19 @@ +let publicInformationBadge = document.querySelector('#public-information-badge'); +if ("{{ user.id }}" == "{{ current_user.hashid }}") { + if ("{{ user.is_public }}" == "True") { + publicInformationBadge.dataset.badgeCaption = 'Your profile is public'; + publicInformationBadge.classList.add('green'); + publicInformationBadge.classList.remove('red'); + } else { + publicInformationBadge.dataset.badgeCaption = 'Your profile is private'; + publicInformationBadge.classList.add('red'); + publicInformationBadge.classList.remove('green'); + } +} else { + publicInformationBadge.remove(); +} + +let followedCorpusList = new FollowedCorpusList(document.querySelector('.followed-corpus-list')); +followedCorpusList.add({{ followed_corpora|tojson }}); +let publicCorpusList = new PublicCorpusList(document.querySelector('.public-corpus-list')); +publicCorpusList.add({{ own_public_corpora|tojson }}); diff --git a/app/users/__init__.py b/app/users/__init__.py index 885cdbe2..705b3afa 100644 --- a/app/users/__init__.py +++ b/app/users/__init__.py @@ -2,4 +2,5 @@ from flask import Blueprint bp = Blueprint('users', __name__) -from . import events, routes +from . import events, routes, json_routes + diff --git a/app/users/json_routes.py b/app/users/json_routes.py new file mode 100644 index 00000000..0e421078 --- /dev/null +++ b/app/users/json_routes.py @@ -0,0 +1,54 @@ +from flask import abort, current_app +from flask_login import current_user, login_required, logout_user +from threading import Thread +import os +from app import db +from app.decorators import content_negotiation +from app.models import Avatar, User +from . import bp + +@bp.route('/', methods=['DELETE']) +@login_required +@content_negotiation(produces='application/json') +def delete_user(user_id): + def _delete_user(app, user_id): + with app.app_context(): + user = User.query.get(user_id) + user.delete() + db.session.commit() + + user = User.query.get_or_404(user_id) + if not (user == current_user or current_user.is_administrator()): + abort(403) + thread = Thread( + target=_delete_user, + args=(current_app._get_current_object(), user_id) + ) + if user == current_user: + logout_user() + thread.start() + response_data = { + 'message': f'User "{user.username}" marked for deletion' + } + return response_data, 202 + +@bp.route('//avatar', methods=['DELETE']) +@content_negotiation(produces='application/json') +def delete_profile_avatar(user_id): + def _delete_avatar(app, avatar_id): + with app.app_context(): + avatar = Avatar.query.get(avatar_id) + avatar.delete() + db.session.commit() + user = User.query.get_or_404(user_id) + if user.avatar is None: + abort(404) + thread = Thread( + target=_delete_avatar, + args=(current_app._get_current_object(), user.avatar.id) + ) + thread.start() + response_data = { + 'message': f'Avatar marked for deletion' + } + return response_data, 202 diff --git a/app/users/routes.py b/app/users/routes.py index d573da34..5d8ff266 100644 --- a/app/users/routes.py +++ b/app/users/routes.py @@ -51,24 +51,7 @@ def user(user_id): user_id=user_id ) -@bp.route('/', methods=['DELETE']) -@login_required -def delete_user(user_id): - def _delete_user(app, user_id): - with app.app_context(): - user = User.query.get(user_id) - user.delete() - db.session.commit() - user = User.query.get_or_404(user_id) - if not (user == current_user or current_user.is_administrator()): - abort(403) - thread = Thread( - target=_delete_user, - args=(current_app._get_current_object(), user_id) - ) - thread.start() - return {}, 202 @bp.route('//avatar') def profile_avatar(user_id): @@ -86,24 +69,6 @@ def profile_avatar(user_id): ) -@bp.route('//avatar', methods=['DELETE']) -def delete_profile_avatar(user_id): - def _delete_avatar(app, avatar_id): - with app.app_context(): - avatar = Avatar.query.get(avatar_id) - avatar.delete() - db.session.commit() - user = User.query.get_or_404(user_id) - if user.avatar is None: - abort(404) - thread = Thread( - target=_delete_avatar, - args=(current_app._get_current_object(), user.avatar.id) - ) - thread.start() - return {}, 202 - - @bp.route('//edit', methods=['GET', 'POST']) def edit_profile(user_id): user = User.query.get_or_404(user_id) From 4c05c6cc18b4973bb158f978517eccb179fa7f75 Mon Sep 17 00:00:00 2001 From: Inga Kirschnick Date: Mon, 13 Mar 2023 15:04:44 +0100 Subject: [PATCH 063/177] merge settings into users route --- app/__init__.py | 3 - app/admin/routes.py | 2 +- app/settings/__init__.py | 5 -- app/settings/forms.py | 43 ----------- app/settings/routes.py | 39 ---------- app/templates/_navbar.html.j2 | 3 +- app/templates/_sidenav.html.j2 | 3 +- app/templates/settings/_breadcrumbs.html.j2 | 6 -- app/templates/settings/settings.html.j2 | 84 --------------------- app/templates/users/edit_profile.html.j2 | 77 +++++++++++++++++-- app/templates/users/edit_profile.js.j2 | 5 ++ app/users/forms.py | 43 ++++++++++- app/users/routes.py | 36 ++++++++- 13 files changed, 157 insertions(+), 192 deletions(-) delete mode 100644 app/settings/__init__.py delete mode 100644 app/settings/forms.py delete mode 100644 app/settings/routes.py delete mode 100644 app/templates/settings/_breadcrumbs.html.j2 delete mode 100644 app/templates/settings/settings.html.j2 diff --git a/app/__init__.py b/app/__init__.py index dcb58e47..09e479a8 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -81,9 +81,6 @@ def create_app(config: Config = Config) -> Flask: from .services import bp as services_blueprint app.register_blueprint(services_blueprint, url_prefix='/services') - from .settings import bp as settings_blueprint - app.register_blueprint(settings_blueprint, url_prefix='/settings') - from .users import bp as users_blueprint app.register_blueprint(users_blueprint, url_prefix='/users') diff --git a/app/admin/routes.py b/app/admin/routes.py index 08f219ab..95ce7730 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -4,7 +4,7 @@ from threading import Thread from app import db, hashids from app.decorators import admin_required from app.models import Role, User, UserSettingJobStatusMailNotificationLevel -from app.settings.forms import ( +from app.users.forms import ( EditNotificationSettingsForm ) from app.users.forms import EditProfileSettingsForm diff --git a/app/settings/__init__.py b/app/settings/__init__.py deleted file mode 100644 index 1dab5142..00000000 --- a/app/settings/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from flask import Blueprint - - -bp = Blueprint('settings', __name__) -from . import routes # noqa diff --git a/app/settings/forms.py b/app/settings/forms.py deleted file mode 100644 index e90d9dda..00000000 --- a/app/settings/forms.py +++ /dev/null @@ -1,43 +0,0 @@ -from flask_wtf import FlaskForm -from wtforms import PasswordField, SelectField, SubmitField, ValidationError -from wtforms.validators import DataRequired, EqualTo -from app.models import UserSettingJobStatusMailNotificationLevel - - -class ChangePasswordForm(FlaskForm): - password = PasswordField('Old password', validators=[DataRequired()]) - new_password = PasswordField( - 'New password', - validators=[ - DataRequired(), - EqualTo('new_password_2', message='Passwords must match') - ] - ) - new_password_2 = PasswordField( - 'New password confirmation', - validators=[ - DataRequired(), - EqualTo('new_password', message='Passwords must match') - ] - ) - submit = SubmitField() - - def __init__(self, user, *args, **kwargs): - super().__init__(*args, **kwargs) - self.user = user - - def validate_current_password(self, field): - if not self.user.verify_password(field.data): - raise ValidationError('Invalid password') - - -class EditNotificationSettingsForm(FlaskForm): - job_status_mail_notification_level = SelectField( - 'Job status mail notification level', - choices=[ - (x.name, x.name.capitalize()) - for x in UserSettingJobStatusMailNotificationLevel - ], - validators=[DataRequired()] - ) - submit = SubmitField() diff --git a/app/settings/routes.py b/app/settings/routes.py deleted file mode 100644 index a07869b8..00000000 --- a/app/settings/routes.py +++ /dev/null @@ -1,39 +0,0 @@ -from flask import flash, redirect, render_template, url_for -from flask_login import current_user, login_required -from app import db -from app.models import UserSettingJobStatusMailNotificationLevel -from . import bp -from .forms import ChangePasswordForm, EditNotificationSettingsForm - - -@bp.route('', methods=['GET', 'POST']) -@login_required -def settings(): - change_password_form = ChangePasswordForm( - current_user, - prefix='change-password-form' - ) - edit_notification_settings_form = EditNotificationSettingsForm( - data=current_user.to_json_serializeable(), - prefix='edit-notification-settings-form' - ) - # region handle change_password_form POST - if change_password_form.submit.data and change_password_form.validate(): - current_user.password = change_password_form.new_password.data - db.session.commit() - flash('Your changes have been saved') - return redirect(url_for('.settings')) - # endregion handle change_password_form POST - # region handle edit_notification_settings_form POST - if edit_notification_settings_form.submit and edit_notification_settings_form.validate(): - current_user.setting_job_status_mail_notification_level = edit_notification_settings_form.job_status_mail_notification_level.data - db.session.commit() - flash('Your changes have been saved') - return redirect(url_for('.settings')) - # endregion handle edit_notification_settings_form POST - return render_template( - 'settings/settings.html.j2', - change_password_form=change_password_form, - edit_notification_settings_form=edit_notification_settings_form, - title='Settings' - ) diff --git a/app/templates/_navbar.html.j2 b/app/templates/_navbar.html.j2 index d943c0e4..c4c4d3b7 100644 --- a/app/templates/_navbar.html.j2 +++ b/app/templates/_navbar.html.j2 @@ -29,8 +29,7 @@ - {% if current_user.is_authenticated %} + {# {% if current_user.is_authenticated %} explore - {% endif %} + {% endif %} #} + help + + {% block admin_settings %}{% endblock admin_settings %} {% endblock page_content %} @@ -184,11 +185,16 @@ {% endblock modals %} @@ -196,25 +202,25 @@ {% block scripts %} {{ super() }} {%- endassets %} diff --git a/app/templates/settings/settings.html.j2 b/app/templates/settings/settings.html.j2 index 004a553a..27c9590c 100644 --- a/app/templates/settings/settings.html.j2 +++ b/app/templates/settings/settings.html.j2 @@ -221,7 +221,7 @@ deleteAvatarButtonElement.addEventListener('click', () => { }); document.querySelector('#delete-user').addEventListener('click', (event) => { - Requests.settings.entity.delete({{ user.hashid|tojson }}) + Requests.users.entity.delete({{ user.hashid|tojson }}) .then((response) => {window.location.href = '/';}); }); diff --git a/app/users/json_routes.py b/app/users/json_routes.py new file mode 100644 index 00000000..d228f8f3 --- /dev/null +++ b/app/users/json_routes.py @@ -0,0 +1,33 @@ +from flask import abort, current_app +from flask_login import current_user, login_required, logout_user +from threading import Thread +from app import db +from app.decorators import content_negotiation +from app.models import User +from . import bp + + +@bp.route('/', methods=['DELETE']) +@login_required +@content_negotiation(produces='application/json') +def delete_user(user_id): + def _delete_user(app, user_id): + with app.app_context(): + user = User.query.get(user_id) + user.delete() + db.session.commit() + + user = User.query.get_or_404(user_id) + if not (user == current_user or current_user.is_administrator()): + abort(403) + thread = Thread( + target=_delete_user, + args=(current_app._get_current_object(), user_id) + ) + if user == current_user: + logout_user() + thread.start() + response_data = { + 'message': f'User "{user.username}" marked for deletion' + } + return response_data, 202 From cca0185500db069eda6c522e0ba299a2115305e1 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Fri, 31 Mar 2023 09:13:55 +0200 Subject: [PATCH 106/177] Remove latest extension recommendation --- .vscode/extensions.json | 1 - .vscode/settings.json | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index e3d1f58e..ecf868e0 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,7 +1,6 @@ { "recommendations": [ "samuelcolvin.jinjahtml", - "streetsidesoftware.code-spell-checker", "ms-azuretools.vscode-docker", "ms-python.python" ] diff --git a/.vscode/settings.json b/.vscode/settings.json index 4de986b9..5879e013 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,8 +18,5 @@ }, "[jinja-js]": { "editor.tabSize": 2 - }, - "cSpell.words": [ - "hashid" - ], + } } From cff4b2c5880556fe0c531530d967dd23527d76cf Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Fri, 31 Mar 2023 09:14:21 +0200 Subject: [PATCH 107/177] Fix problems with new forms --- app/admin/forms.py | 8 +++-- app/auth/forms.py | 30 +++++++++++++++---- app/contributions/forms.py | 6 ++-- .../spacy_nlp_pipeline_models/forms.py | 8 +++-- .../spacy_nlp_pipeline_models/routes.py | 4 +-- .../tesseract_ocr_pipeline_models/forms.py | 8 +++-- .../tesseract_ocr_pipeline_models/routes.py | 4 +-- app/corpora/files/forms.py | 14 +++++++-- app/corpora/forms.py | 21 +++++++++---- app/forms.py | 26 ---------------- app/services/forms.py | 12 ++++++-- app/services/routes.py | 8 ++--- app/settings/forms.py | 29 +++++++++++++----- .../services/corpus_analysis.html.j2 | 29 +----------------- app/wtforms/__init__.py | 0 app/wtforms/validators.py | 14 +++++++++ 16 files changed, 126 insertions(+), 95 deletions(-) delete mode 100644 app/forms.py create mode 100644 app/wtforms/__init__.py create mode 100644 app/wtforms/validators.py diff --git a/app/admin/forms.py b/app/admin/forms.py index f24ce8f3..ea684624 100644 --- a/app/admin/forms.py +++ b/app/admin/forms.py @@ -1,14 +1,16 @@ -from wtforms import BooleanField, SelectField, SubmitField -from app.forms import NopaqueForm +from flask_wtf import FlaskForm +from wtforms import SelectField, SubmitField from app.models import Role -class UpdateUserForm(NopaqueForm): +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/auth/forms.py b/app/auth/forms.py index 43db510a..d8ca5770 100644 --- a/app/auth/forms.py +++ b/app/auth/forms.py @@ -1,3 +1,4 @@ +from flask_wtf import FlaskForm from wtforms import ( BooleanField, PasswordField, @@ -6,11 +7,10 @@ from wtforms import ( ValidationError ) from wtforms.validators import InputRequired, Email, EqualTo, Length, Regexp -from app.forms import NopaqueForm from app.models import User -class RegistrationForm(NopaqueForm): +class RegistrationForm(FlaskForm): email = StringField( 'Email', validators=[InputRequired(), Email(), Length(max=254)] @@ -45,6 +45,11 @@ class RegistrationForm(NopaqueForm): ) submit = SubmitField() + def __init__(self, *args, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'registration-form' + super().__init__(*args, **kwargs) + def validate_email(self, field): if User.query.filter_by(email=field.data.lower()).first(): raise ValidationError('Email already registered') @@ -54,19 +59,29 @@ class RegistrationForm(NopaqueForm): raise ValidationError('Username already in use') -class LoginForm(NopaqueForm): +class LoginForm(FlaskForm): user = StringField('Email or username', validators=[InputRequired()]) password = PasswordField('Password', validators=[InputRequired()]) remember_me = BooleanField('Keep me logged in') submit = SubmitField() + def __init__(self, *args, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'login-form' + super().__init__(*args, **kwargs) -class ResetPasswordRequestForm(NopaqueForm): + +class ResetPasswordRequestForm(FlaskForm): email = StringField('Email', validators=[InputRequired(), Email()]) submit = SubmitField() + def __init__(self, *args, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'reset-password-request-form' + super().__init__(*args, **kwargs) -class ResetPasswordForm(NopaqueForm): + +class ResetPasswordForm(FlaskForm): password = PasswordField( 'New password', validators=[ @@ -82,3 +97,8 @@ class ResetPasswordForm(NopaqueForm): ] ) submit = SubmitField() + + def __init__(self, *args, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'reset-password-form' + super().__init__(*args, **kwargs) diff --git a/app/contributions/forms.py b/app/contributions/forms.py index 46777ff0..598fb7cc 100644 --- a/app/contributions/forms.py +++ b/app/contributions/forms.py @@ -1,3 +1,4 @@ +from flask_wtf import FlaskForm from wtforms import ( StringField, SubmitField, @@ -5,10 +6,9 @@ from wtforms import ( IntegerField ) from wtforms.validators import InputRequired, Length -from app.forms import NopaqueForm -class ContributionBaseForm(NopaqueForm): +class ContributionBaseForm(FlaskForm): title = StringField( 'Title', validators=[InputRequired(), Length(max=64)] @@ -43,5 +43,5 @@ class ContributionBaseForm(NopaqueForm): submit = SubmitField() -class EditContributionBaseForm(ContributionBaseForm): +class UpdateContributionBaseForm(ContributionBaseForm): pass diff --git a/app/contributions/spacy_nlp_pipeline_models/forms.py b/app/contributions/spacy_nlp_pipeline_models/forms.py index 2670c1d1..dc3ca781 100644 --- a/app/contributions/spacy_nlp_pipeline_models/forms.py +++ b/app/contributions/spacy_nlp_pipeline_models/forms.py @@ -2,7 +2,7 @@ from flask_wtf.file import FileField, FileRequired from wtforms import StringField, ValidationError from wtforms.validators import InputRequired, Length from app.services import SERVICES -from ..forms import ContributionBaseForm, EditContributionBaseForm +from ..forms import ContributionBaseForm, UpdateContributionBaseForm class CreateSpaCyNLPPipelineModelForm(ContributionBaseForm): @@ -20,6 +20,8 @@ class CreateSpaCyNLPPipelineModelForm(ContributionBaseForm): raise ValidationError('.tar.gz files only!') def __init__(self, *args, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'create-spacy-nlp-pipeline-model-form' super().__init__(*args, **kwargs) service_manifest = SERVICES['spacy-nlp-pipeline'] self.compatible_service_versions.choices = [('', 'Choose your option')] @@ -29,12 +31,14 @@ class CreateSpaCyNLPPipelineModelForm(ContributionBaseForm): self.compatible_service_versions.default = '' -class EditSpaCyNLPPipelineModelForm(EditContributionBaseForm): +class UpdateSpaCyNLPPipelineModelForm(UpdateContributionBaseForm): pipeline_name = StringField( 'Pipeline name', validators=[InputRequired(), Length(max=64)] ) def __init__(self, *args, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'edit-spacy-nlp-pipeline-model-form' super().__init__(*args, **kwargs) service_manifest = SERVICES['spacy-nlp-pipeline'] self.compatible_service_versions.choices = [('', 'Choose your option')] diff --git a/app/contributions/spacy_nlp_pipeline_models/routes.py b/app/contributions/spacy_nlp_pipeline_models/routes.py index eab8ac49..f53d55f1 100644 --- a/app/contributions/spacy_nlp_pipeline_models/routes.py +++ b/app/contributions/spacy_nlp_pipeline_models/routes.py @@ -6,7 +6,7 @@ from app.models import SpaCyNLPPipelineModel from . import bp from .forms import ( CreateSpaCyNLPPipelineModelForm, - EditSpaCyNLPPipelineModelForm + UpdateSpaCyNLPPipelineModelForm ) from .utils import ( spacy_nlp_pipeline_model_dlc as spacy_nlp_pipeline_model_dlc @@ -63,7 +63,7 @@ def create_spacy_nlp_pipeline_model(): @login_required def spacy_nlp_pipeline_model(spacy_nlp_pipeline_model_id): snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id) - form = EditSpaCyNLPPipelineModelForm(data=snpm.to_json_serializeable()) + form = UpdateSpaCyNLPPipelineModelForm(data=snpm.to_json_serializeable()) if form.validate_on_submit(): form.populate_obj(snpm) if db.session.is_modified(snpm): diff --git a/app/contributions/tesseract_ocr_pipeline_models/forms.py b/app/contributions/tesseract_ocr_pipeline_models/forms.py index 51f0d76c..9a5979dd 100644 --- a/app/contributions/tesseract_ocr_pipeline_models/forms.py +++ b/app/contributions/tesseract_ocr_pipeline_models/forms.py @@ -1,7 +1,7 @@ from flask_wtf.file import FileField, FileRequired from wtforms import ValidationError from app.services import SERVICES -from ..forms import ContributionBaseForm, EditContributionBaseForm +from ..forms import ContributionBaseForm, UpdateContributionBaseForm class CreateTesseractOCRPipelineModelForm(ContributionBaseForm): @@ -15,6 +15,8 @@ class CreateTesseractOCRPipelineModelForm(ContributionBaseForm): raise ValidationError('traineddata files only!') def __init__(self, *args, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'create-tesseract-ocr-pipeline-model-form' service_manifest = SERVICES['tesseract-ocr-pipeline'] super().__init__(*args, **kwargs) self.compatible_service_versions.choices = [('', 'Choose your option')] @@ -24,8 +26,10 @@ class CreateTesseractOCRPipelineModelForm(ContributionBaseForm): self.compatible_service_versions.default = '' -class EditTesseractOCRPipelineModelForm(EditContributionBaseForm): +class UpdateTesseractOCRPipelineModelForm(UpdateContributionBaseForm): def __init__(self, *args, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'edit-tesseract-ocr-pipeline-model-form' service_manifest = SERVICES['tesseract-ocr-pipeline'] super().__init__(*args, **kwargs) self.compatible_service_versions.choices = [('', 'Choose your option')] diff --git a/app/contributions/tesseract_ocr_pipeline_models/routes.py b/app/contributions/tesseract_ocr_pipeline_models/routes.py index 51adf402..e0261e80 100644 --- a/app/contributions/tesseract_ocr_pipeline_models/routes.py +++ b/app/contributions/tesseract_ocr_pipeline_models/routes.py @@ -6,7 +6,7 @@ from app.models import TesseractOCRPipelineModel from . import bp from .forms import ( CreateTesseractOCRPipelineModelForm, - EditTesseractOCRPipelineModelForm + UpdateTesseractOCRPipelineModelForm ) from .utils import ( tesseract_ocr_pipeline_model_dlc as tesseract_ocr_pipeline_model_dlc @@ -62,7 +62,7 @@ def create_tesseract_ocr_pipeline_model(): @login_required def tesseract_ocr_pipeline_model(tesseract_ocr_pipeline_model_id): topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id) - form = EditTesseractOCRPipelineModelForm(data=topm.to_json_serializeable()) + form = UpdateTesseractOCRPipelineModelForm(data=topm.to_json_serializeable()) if form.validate_on_submit(): form.populate_obj(topm) if db.session.is_modified(topm): diff --git a/app/corpora/files/forms.py b/app/corpora/files/forms.py index c819fba2..e6918a83 100644 --- a/app/corpora/files/forms.py +++ b/app/corpora/files/forms.py @@ -1,3 +1,4 @@ +from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileRequired from wtforms import ( StringField, @@ -6,10 +7,9 @@ from wtforms import ( IntegerField ) from wtforms.validators import InputRequired, Length -from app.forms import NopaqueForm -class CorpusFileBaseForm(NopaqueForm): +class CorpusFileBaseForm(FlaskForm): author = StringField( 'Author', validators=[InputRequired(), Length(max=255)] @@ -41,6 +41,14 @@ class CreateCorpusFileForm(CorpusFileBaseForm): if not field.data.filename.lower().endswith('.vrt'): raise ValidationError('VRT files only!') + def __init__(self, *args, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'create-corpus-file-form' + super().__init__(*args, **kwargs) + class UpdateCorpusFileForm(CorpusFileBaseForm): - pass + def __init__(self, *args, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'update-corpus-file-form' + super().__init__(*args, **kwargs) diff --git a/app/corpora/forms.py b/app/corpora/forms.py index e923ca1b..fa8ccd05 100644 --- a/app/corpora/forms.py +++ b/app/corpora/forms.py @@ -1,9 +1,9 @@ +from flask_wtf import FlaskForm from wtforms import StringField, SubmitField, TextAreaField from wtforms.validators import InputRequired, Length -from app.forms import NopaqueForm -class CorpusBaseForm(NopaqueForm): +class CorpusBaseForm(FlaskForm): description = TextAreaField( 'Description', validators=[InputRequired(), Length(max=255)] @@ -13,12 +13,21 @@ class CorpusBaseForm(NopaqueForm): class CreateCorpusForm(CorpusBaseForm): - pass + def __init__(self, *args, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'create-corpus-form' + super().__init__(*args, **kwargs) class UpdateCorpusForm(CorpusBaseForm): - pass + def __init__(self, *args, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'update-corpus-form' + super().__init__(*args, **kwargs) -class ImportCorpusForm(NopaqueForm): - pass +class ImportCorpusForm(FlaskForm): + def __init__(self, *args, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'import-corpus-form' + super().__init__(*args, **kwargs) diff --git a/app/forms.py b/app/forms.py deleted file mode 100644 index 6ac58347..00000000 --- a/app/forms.py +++ /dev/null @@ -1,26 +0,0 @@ -from flask_wtf import FlaskForm -from wtforms.validators import ValidationError -import re - - -form_prefix_pattern = re.compile(r'(?= max_size_b: - raise ValidationError( - f'File size must be less or equal than {max_size_mb} MB' - ) - field.data.seek(0) - return file_length_check - - -class NopaqueForm(FlaskForm): - def __init__(self, *args, **kwargs): - if 'prefix' not in kwargs: - kwargs['prefix'] = \ - form_prefix_pattern.sub('-', self.__class__.__name__).lower() - super().__init__(*args, **kwargs) - diff --git a/app/services/forms.py b/app/services/forms.py index d53132f8..01b1cdd8 100644 --- a/app/services/forms.py +++ b/app/services/forms.py @@ -1,3 +1,4 @@ +from flask_wtf import FlaskForm from flask_login import current_user from flask_wtf.file import FileField, FileRequired from wtforms import ( @@ -10,12 +11,11 @@ from wtforms import ( ValidationError ) from wtforms.validators import InputRequired, Length -from app.forms import NopaqueForm from app.models import SpaCyNLPPipelineModel, TesseractOCRPipelineModel from . import SERVICES -class CreateJobBaseForm(NopaqueForm): +class CreateJobBaseForm(FlaskForm): description = StringField( 'Description', validators=[InputRequired(), Length(max=255)] @@ -38,6 +38,8 @@ class CreateFileSetupPipelineJobForm(CreateJobBaseForm): raise ValidationError('JPEG, PNG and TIFF files only!') def __init__(self, *args, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'create-file-setup-pipeline-job-form' service_manifest = SERVICES['file-setup-pipeline'] version = kwargs.pop('version', service_manifest['latest_version']) super().__init__(*args, **kwargs) @@ -65,6 +67,8 @@ class CreateTesseractOCRPipelineJobForm(CreateJobBaseForm): raise ValidationError('PDF files only!') def __init__(self, *args, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'create-tesseract-ocr-pipeline-job-form' service_manifest = SERVICES['tesseract-ocr-pipeline'] version = kwargs.pop('version', service_manifest['latest_version']) super().__init__(*args, **kwargs) @@ -111,6 +115,8 @@ class CreateTranskribusHTRPipelineJobForm(CreateJobBaseForm): raise ValidationError('PDF files only!') def __init__(self, *args, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'create-transkribus-htr-pipeline-job-form' transkribus_htr_pipeline_models = kwargs.pop('transkribus_htr_pipeline_models', []) service_manifest = SERVICES['transkribus-htr-pipeline'] version = kwargs.pop('version', service_manifest['latest_version']) @@ -149,6 +155,8 @@ class CreateSpacyNLPPipelineJobForm(CreateJobBaseForm): raise ValidationError('Plain text files only!') def __init__(self, *args, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'create-spacy-nlp-pipeline-job-form' service_manifest = SERVICES['spacy-nlp-pipeline'] version = kwargs.pop('version', service_manifest['latest_version']) super().__init__(*args, **kwargs) diff --git a/app/services/routes.py b/app/services/routes.py index 3a3cbd7b..7ab36384 100644 --- a/app/services/routes.py +++ b/app/services/routes.py @@ -35,7 +35,7 @@ def file_setup_pipeline(): version = request.args.get('version', service_manifest['latest_version']) if version not in service_manifest['versions']: abort(404) - form = CreateFileSetupPipelineJobForm(version=version) + form = CreateFileSetupPipelineJobForm(prefix='create-job-form', version=version) if form.is_submitted(): if not form.validate(): response = {'errors': form.errors} @@ -77,7 +77,7 @@ def tesseract_ocr_pipeline(): version = request.args.get('version', service_manifest['latest_version']) if version not in service_manifest['versions']: abort(404) - form = CreateTesseractOCRPipelineJobForm(version=version) + form = CreateTesseractOCRPipelineJobForm(prefix='create-job-form', version=version) if form.is_submitted(): if not form.validate(): response = {'errors': form.errors} @@ -137,8 +137,8 @@ def transkribus_htr_pipeline(): abort(500) transkribus_htr_pipeline_models = r.json()['trpModelMetadata'] transkribus_htr_pipeline_models.append({'modelId': 48513, 'name': 'Caroline Minuscle', 'language': 'lat', 'isoLanguages': ['lat']}) - print(transkribus_htr_pipeline_models[len(transkribus_htr_pipeline_models)-1]) form = CreateTranskribusHTRPipelineJobForm( + prefix='create-job-form', transkribus_htr_pipeline_models=transkribus_htr_pipeline_models, version=version ) @@ -186,7 +186,7 @@ def spacy_nlp_pipeline(): version = request.args.get('version', SERVICES[service]['latest_version']) if version not in service_manifest['versions']: abort(404) - form = CreateSpacyNLPPipelineJobForm(version=version) + form = CreateSpacyNLPPipelineJobForm(prefix='create-job-form', version=version) spacy_nlp_pipeline_models = SpaCyNLPPipelineModel.query.all() if form.is_submitted(): if not form.validate(): diff --git a/app/settings/forms.py b/app/settings/forms.py index 6778bbe6..77c8687c 100644 --- a/app/settings/forms.py +++ b/app/settings/forms.py @@ -1,4 +1,5 @@ from flask_login import current_user +from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileRequired from wtforms import ( PasswordField, @@ -15,11 +16,11 @@ from wtforms.validators import ( Length, Regexp ) -from app.forms import NopaqueForm, LimitFileSize from app.models import User, UserSettingJobStatusMailNotificationLevel +from app.wtforms.validators import FileSize -class UpdateAccountInformationForm(NopaqueForm): +class UpdateAccountInformationForm(FlaskForm): email = StringField( 'E-Mail', validators=[DataRequired(), Length(max=254), Email()] @@ -43,6 +44,8 @@ class UpdateAccountInformationForm(NopaqueForm): def __init__(self, *args, user=current_user, **kwargs): if 'data' not in kwargs: kwargs['data'] = user.to_json_serializeable() + if 'prefix' not in kwargs: + kwargs['prefix'] = 'update-account-information-form' super().__init__(*args, **kwargs) self.user = user @@ -57,7 +60,7 @@ class UpdateAccountInformationForm(NopaqueForm): raise ValidationError('Username already in use') -class UpdateProfileInformationForm(NopaqueForm): +class UpdateProfileInformationForm(FlaskForm): full_name = StringField( 'Full name', validators=[Length(max=128)] @@ -91,11 +94,13 @@ class UpdateProfileInformationForm(NopaqueForm): def __init__(self, *args, user=current_user, **kwargs): if 'data' not in kwargs: kwargs['data'] = user.to_json_serializeable() + if 'prefix' not in kwargs: + kwargs['prefix'] = 'update-profile-information-form' super().__init__(*args, **kwargs) -class UpdateAvatarForm(NopaqueForm): - avatar = FileField('File', validators=[FileRequired(), LimitFileSize(2)]) +class UpdateAvatarForm(FlaskForm): + avatar = FileField('File', validators=[FileRequired(), FileSize(2)]) submit = SubmitField() def validate_avatar(self, field): @@ -103,7 +108,13 @@ class UpdateAvatarForm(NopaqueForm): if field.data.mimetype not in valid_mimetypes: raise ValidationError('JPEG and PNG files only!') -class UpdatePasswordForm(NopaqueForm): + def __init__(self, *args, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'update-avatar-form' + super().__init__(*args, **kwargs) + + +class UpdatePasswordForm(FlaskForm): password = PasswordField('Old password', validators=[DataRequired()]) new_password = PasswordField( 'New password', @@ -122,6 +133,8 @@ class UpdatePasswordForm(NopaqueForm): submit = SubmitField() def __init__(self, *args, user=current_user, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'update-password-form' super().__init__(*args, **kwargs) self.user = user @@ -130,7 +143,7 @@ class UpdatePasswordForm(NopaqueForm): raise ValidationError('Invalid password') -class UpdateNotificationsForm(NopaqueForm): +class UpdateNotificationsForm(FlaskForm): job_status_mail_notification_level = SelectField( 'Job status mail notification level', choices=[ @@ -144,4 +157,6 @@ class UpdateNotificationsForm(NopaqueForm): def __init__(self, *args, user=current_user, **kwargs): if 'data' not in kwargs: kwargs['data'] = user.to_json_serializeable() + if 'prefix' not in kwargs: + kwargs['prefix'] = 'update-notifications-form' super().__init__(*args, **kwargs) diff --git a/app/templates/services/corpus_analysis.html.j2 b/app/templates/services/corpus_analysis.html.j2 index 9ddc9ec3..47fc6a47 100644 --- a/app/templates/services/corpus_analysis.html.j2 +++ b/app/templates/services/corpus_analysis.html.j2 @@ -28,38 +28,11 @@
            - -
            -

            My query results

            -
            -
            -
            - search - - -
            - - - - - - - - - -
            Title and DescriptionCorpus and Query
            -
              -
              - -
              -
              {% endblock page_content %} diff --git a/app/wtforms/__init__.py b/app/wtforms/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/wtforms/validators.py b/app/wtforms/validators.py new file mode 100644 index 00000000..bef95681 --- /dev/null +++ b/app/wtforms/validators.py @@ -0,0 +1,14 @@ +from wtforms.validators import ValidationError + + +def FileSize(max_size_mb): + max_size_b = max_size_mb * 1024 * 1024 + + def file_length_check(form, field): + if len(field.data.read()) >= max_size_b: + raise ValidationError( + f'File size must be less or equal than {max_size_mb} MB' + ) + field.data.seek(0) + + return file_length_check From 43751b44ac23aa1e8e8e194cf862229c53a8d270 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Fri, 31 Mar 2023 09:25:13 +0200 Subject: [PATCH 108/177] Fix missing job status value in toast notification --- app/static/js/App.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/static/js/App.js b/app/static/js/App.js index 87d44d71..6d54a7a5 100644 --- a/app/static/js/App.js +++ b/app/static/js/App.js @@ -91,7 +91,7 @@ class App { .filter((operation) => {return subRegExp.test(operation.path);}); for (let operation of subFilteredPatch) { let [match, userId, jobId] = operation.path.match(subRegExp); - this.flash(`[${this.data.users[userId].jobs[jobId].title}] New status: `, 'job'); + this.flash(`[${this.data.users[userId].jobs[jobId].title}] New status: `, 'job'); } // Apply Patch From 491e24f0a3339d3cbbe239223a974b489995f194 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Fri, 31 Mar 2023 09:32:59 +0200 Subject: [PATCH 109/177] Fix jobinputlist --- app/static/js/ResourceLists/JobInputList.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/static/js/ResourceLists/JobInputList.js b/app/static/js/ResourceLists/JobInputList.js index 7f1a5105..97a8dd14 100644 --- a/app/static/js/ResourceLists/JobInputList.js +++ b/app/static/js/ResourceLists/JobInputList.js @@ -12,11 +12,7 @@ class JobInputList extends ResourceList { 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);} - }); - }); + app.subscribeUser(this.userId); app.getUser(this.userId).then((user) => { this.add(Object.values(user.jobs[this.jobId].inputs)); this.isInitialized = true; From 9167bffa61ea18948e2e65f4e8454183b7c66c1c Mon Sep 17 00:00:00 2001 From: Inga Kirschnick Date: Fri, 31 Mar 2023 11:46:17 +0200 Subject: [PATCH 110/177] New user avatar --- app/settings/forms.py | 2 +- app/static/images/user_avatar.png | Bin 2011 -> 14567 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/app/settings/forms.py b/app/settings/forms.py index 6778bbe6..e14ab91a 100644 --- a/app/settings/forms.py +++ b/app/settings/forms.py @@ -95,7 +95,7 @@ class UpdateProfileInformationForm(NopaqueForm): class UpdateAvatarForm(NopaqueForm): - avatar = FileField('File', validators=[FileRequired(), LimitFileSize(2)]) + avatar = FileField('File', validators=[LimitFileSize(2)]) submit = SubmitField() def validate_avatar(self, field): diff --git a/app/static/images/user_avatar.png b/app/static/images/user_avatar.png index 09892098aa943af5fe95e7dd434a07fe03e7ccd1..01dfaa60ee378b82d15034d459111dafee0738dd 100644 GIT binary patch literal 14567 zcmbWe2UJr{*D!i$0)ir4il735RHds(9zmLjp;sZ&K}zT#ghW9>x`2S7f>Kokq)Ux- z1ToSjNDBy3q6sAg((dv3{_lJLb=SRX{rB!1uNMHAnE~-v4pXIo@FBm>7RXQHnU zGq;2qn%p$_cTmhHU4w%B|27T?3=6R|(mQSM;CPyS6&%gq@qpm!9vXDb-2CQW{J($y z;{O5C)PL0-SNhx67Of;*`;k#kKtl&K8hcXZKlT0}v7;WIq3+-lDp)SK2Ze-zj8Ft| z^T@EEzc>QK1wz0o#1KV5!@BHn-6U0q`K)@YP1{6VD6}SKv)xUfO+k!xv{9pW2{Lj7k1pv5; zW-u6S|G9Sq4FI)T0C2SBKlkLc0f3ha00vuvTti&{?T!V!GJAm%wp{@L#~c8F{~OrG zDb!0%_21`A+-OpIPY666#c)4%XL5n#?}rb*WJU@%iG8IZfIC|L}b*x==g_^5)vOLC1+(n&B@JsmS0d@ zQd(AC@#gKjy84F3rsj_=t=&Dnef)I|(DK$%ipj*i3Q6XvdB^lyh(*C9F|BSG>|6i2-4`KhIYZlN4HT*9ygI^YA zW^k%lKwt$)0D_2^1z4UH}5n>2k%%!2gsQ3~E-3el&@4lHMKdAHSTKa)yjt?DQqq5aN7G0%# zBxr~zZV52`HbeLl!9hVNM-Znz77XB2`Y>F+)3yhEPAK2m;h@rp3;lo2#WqbJ)NC#Y zqZUgf3Fa8Sd7qsP7j1G)tmyN%yK|aX<_xv1w0CrSywU_rvQyvRXu)-2$W{#C2ocp~ zt$Pmky}EYM55n%-JbG^ddzn<}DPaEA`I8ykPibW1;(Ehupw<5URkS_>cx-mRTVeIt z_db#>EPQU^2NKQzcoQ%7U-3N2X%-oYLdn-LfKM4)JTL|jACFpmdYP?fR;#z7-Q$m6 z=|;%g!wZdA)aA7R$RcimEOTli?vhSqEC;0bP`NCF8u4R#I}&6>7k79KGdZVSjTlNQ zAd4(jKE8D>Zn^Jf^F`X7kB!ZjNQ&pf|B%lv^k}=o$k$Q+JP-25Br3ykJ^_h~?pmyL zn_ouvKe`O>??i6W^afr+PLS*HG?#+rTj)b)44&naB*76)mRabHu-}EHURHWu3mmh; zjNjAU&+LUbQudby`itTjz{#-2DY`&Zg$YrqWc%E>%9&Q^SJ}G^KvxGh7fMb3J@xY`v3l*NAm2Sz9Vbx#txZc9>m-XvUupb)RL>Yy685oGe z&vim|2}PP+vf4&iqxNuA1Oo`id|3J;yp4#lUQ_W_9vh9N#F8LojtYf@niPb}^2;N$ zeY)He0ejAx+ZwrC&mDy9OO4EjY@R*|;ee!tX&jUmkJm#iQnT%@83?>qq;s9oj{(ueHX%xzEiJ~(5@5N1xY?<)|!T0W1Z>mp$Tc8I+gh1 z+8}Qx?CAo*a)B(5!n1?(s7-z=k^DBaJW76k%ivX%%eVTx$bih@ptmD^6Zt@pp0~ey zmJ=QdN)G|5GLuN>Y$RkD(Lj}0tjaoN)Yr_%h32A)H)XN6Pw;$c+VKPG1DcPE`WByY zO|p=BSzciK0rIW=(x;O;$ThLjk>zTA)#-#&KP%M zzrE+p@y+uH3DhJymE3dRQAXP)Sf%wV`-ellc~;_^hpa}?#@jW{n8At{ix-pzn4nlF z9cgWM(0_bAdN}_;DPuMx8Dt}@W){<@dop9G;*&#|WZXP=V{^gRSp&kYJhlP@*6J^9 zvd_#E`QG@$8#H?^-AOh>no_OF3&fJ;@8oxan86)`t0XW z=~Q9qm#>!3wRa|ywzpE)A2NW5(qc+kxsKxSnC2n39$n(mECC$Wrxp>kybZHewDIcfu{Bi}wq-LQ` z79Y=zRiO&XlA=Z%M+^_@Rnm1hN^THYy%hA>+6J?Xt0Fe2Iazy3_h;#;bjP=4M0PsI z+eJyNaF=67{{mTBrSFPl>fy1tBo!T%H?6*Oxet27qU^~w%?^=u(9^0t-YrthY#*ih zsfTuO!ZOag7ZyLSR!2@F6gf}mXwW9ec~Z2oGbEOfpWbti%!y~Mw^#|re|xy zVfjT(!dozk4Y>21X8=BC=Oj3w*o&r427L>4a)unpfywd19Y>3JQPNQ4p;SD{p|9*$!KwFodCgXu6Uir^)?b8NgRRDbTyp`3 z^qgu8UY}B9yv9aGD(*KpB{mc0Q(I)>PZizG7yk2O zbLf;8pY-;;bu}D250BYR@4&3q@J8j92dCdt=0i02&Q0W*X?3(3d9)4<*ZxLx>#z}| z=*JiUnWX~xZ9%eOSW<*O<#K5pi{grMSvkm^7p{M%j=I~%F`HT6;+1n_SS_Kx!6OZ> zTzODRo+P&6FnAH7mMwXJ*aK@Lsw9QC6vVV`4*b|MS>m;3O_m!N7Pu(i#|LD-E55Hx zMUnADHPUnPFis@{b~60P1l0%^Gtl0ipAx{jPdcqKwRmfi?HH%Zk|&=8gx;jC?6m!k0(~@Tfm7`|#@bXaEuehM*ofvj>v87O=_bE*`Q5KPjDPn|qjuXCgxM2>x{j z0#BTLHA6SEDz(34{t%HKFG(xW7D-yOz0`AuIaXyrlHhBUuBf9D8A+I3PD`(>I=hM; zxB4bCj`#3Smu^X*lZ^XSLz=cj`!9_ku0r{@Phw`;_0XjSl(A-9EFN++D$bf#+;b(m z57IKk03J|dEnwvfO&BVlFln6@hp*N#BW84$SQ1{g$rthasEX0#0x!y`X*Y~9pyrhI~WCT6JT&3YX$#&Pz?%@1em<#VGf_RZko z)lpLY(WW_#6I+wH#(_!|N%^+86QlX=M(!d}a}LG1=OrmIpn6E7 zy6n|4WI5Tc9l|CqXTls|LwI^rL;Hi2XwI8%8LHd`)muX077r2PY~eMl{B+JFia`_? zsgEoc%m7%OXf<%Y;eUuW!_J7y#G8dyn591_j*WE%)I}I%iMbZg=EndUYN&kKq!r)Nv<8$oRbz6bW3FZXbbDgj=N)6kPhJ)&z<`A(w~>3 zDklYZ&|9x5rc@nT4Fm9A;HH0L0Nm$x)N6RLigc$F9?j1X^|QmN52RI(819P)!>-$H zCrI&Jwvo9}+>l-G;zRH3!JTn@ad#{-#SYr)Y9L^HZh$dHrzzXwtWO$(s2*6o z4>fK3{9q-uKhP@)##A|nUaV&TP4sw*6LNf%dYP<6a)LcXaV+Wz6P(ME&pOfyXWSET zzg3h?+_tISQNC}(N!EPV)-82!iqG#hDlxn1sRqR%P_7VT%~DoqS9>hp_I zG&oac6gPZ+iGP37v6KPykfaVq4y(Hi1ng#txCJZ>fLL%tW}Tzm@03Ax8KQlicpf0x z^26pd=cV3kV%r}$sN*$dt+UJ@JsnnSD>d(Jum7mjB$IWW0hraySdq-hViaM7$ap6k z5`$Hb)=hGye1mXG~b7Z^aM8n(_hw+LrjVP(s zhSs#DLe3{TA%=^UB_+)7-W*<(4 zNAICQiLg%C;JOt7ZcbI^>_WjR`IRTMO_+iZ@l<%?)_9MXy1POk|k2soa6l< z+4!Mq-HQ`Or{gZM+U>$9!7lcwE(^%wbEF739Z%OYdl`UGC+}zWc)F~#i*<<0jPXC? zd!fuX-n|(&R%xmOv24`x|dR@=wK44y0xr*GJDcNh%^+i>8@cMDEreoV1y&d@qh z)7aMv@Bil*=YX;wrL1P#o5%nj(&a&0PYX0(!F89rv`#h&$xki`ulkaC^+TJ}`QF@V z=Cgedg`fE67~(|F8%c{#AC@3akgJw3=|$`a6=Ghu%BpYS`7wOBsr$lkYm%pVEXT39 zE24BHF+;k7+e`<;L$HA*Z|>Mq@@c}0g)x>CWxhxj)ge&^>ejL>evL6s zr-XBD1}JyO-XJ#V!^KRcB!=$SLhcLFZ!myHbYBr4aths~mDYLQen7J4`h&v_{Y5zQwvXkfeoGY%&en3L5E4Ji@;^S;>`z3_2%eal}C5s^lX3 zgI(HLhk}K{myHU;QQVj_k_z{lB1mekA%ZKxS)mzgb)5uotgorJrgSf$x(syH$;*x% zq7UH08@Za}>1m!7w>8q9oZR4>;&>{tXxFtz$ve7OD5+d>!)d#m=2Ay>f-vK#(Y>qT z8v}t}d1UwB1BAam+9AstbF$nko;_`__bVIpV^iP&pP|@P5J$V-XZP5Wwx3+2=Lcer z5va;z7KJpw-C}e;gscHBg7u?FS$aRVXshDg4}FOa!hl7glWDkfhH_@*&3#`@(`<#Z z%{l2u)`~vgScc8SM&*cY5f&%<_1re3gu?1j}LWSbK_I@I?WuU z%{pnQY-Xrn>8&B~Q`RG6=Nd@N_DmGAhkg`8jN{?%6VM!qEH~cdU^441Fv$KVu%8a(Rd~qG z@<{x64lbOwm$~>evCE{wFWU*($gT4JPW)ujq9$_t7x1%4cA`^p& za60FJ_oDMHE2Xs_n%j=SaFhLPi_@TPa%J$(nmYGJlxmTXMj8+3RZ_+33HA2zMi4BT z6M25aMOf=ctx#)K$5hRg;CB3RK5=uxd10qzsAD1u-nnzGNB@aZjZ?FohyxWGgZ6zS-Q|f zdfUvlIGLVoO!xJdD^oNENTsj-RVXkd>xP=;$4zg^QVe`h4MyEuxlSV*U$myq8JfnL zLZ8-P6|4Ul^eH>aG1QzVKOp}=+Q;ZH0NDd;W&m4rAkpk(#|1vLH|_RqGq*ElFyj{H ztNNye9*UVida}2hrg^_Sdi}KIVT9Zg>NB=Oa!IRSGP#IZC)|^$wK(uNoQI1X50&mh zmH)Wfz9E*NH=5ZZj;Kz18ZKjh!no$^$3HzoB42EbJ8#sE>ZV1YO4iqE54{uV(l}L< z%a$2e&tGYr)ZN^3Ml?gddnGdf2>L#Z5C+Fj6E^0149N?x$dNdum$grR%zb8AcP#r# zQhYVygY134gw_1+@kXp}#(j677fQYY#UU$}-UcA~5rSj8W_NP7e>cY7jkTQl2XevB zM@fC(%?L=EW`7}(LzY3Y?+jY6q-n^^nK%)B>Fm22Jff9e~{m*1bj*ZliNKEn%?dddz8$As%UZ3ZV7X7gUKu@#-gL z*cD6}MB&vBRIk+~Tl2_tXYA$~Y&&gjxU3QXkS0OVXh+VWn$tQ45UFx&(51MYOwt9!#w6F;}06{sb8eaG~Ol8=;HVMDz5KM zn<7nc?5S``Lwm(mlyO!=Tm8er6gi;P!lFd!nD8GQIIMnOLq4yB zA3LsuIQ4dLK|k25XryvmXHK7@qG1|%FP8e00a%f4=U^yya&V+EZGNoc7wUBm=s$n< zCW$a6=`T@>9lFe|WH3<6`kq|o>Gphe{7&5@d!7BlHJu7Vnkf1)7&JHoNvB>R{GlI3 z6HvE_27`?at)1wx$*)1vqJ{a9?tYSzJ7c)0Cou&Kz~~PawPc1KfX)mIppFElPY`d# zzwwPhw+2?tpI&(2KYgG@Jxfl)!DptI(FwYWzD@72m*8!=N4Za>X5qi^8Aa$*_jC{!{FW(5_geJrrM5`ihgIQw7U?7Zd5g zBms*`6NN%v!Dj@8@hV&O#SOC=C8t>4pB5#Tv#r-Fn~O~=y)*3a&0Be(Y|ugf0&>?W zs$IV_Do`Z>%NiflK@2r@*LmQE6BBYe{dE`qSzp84Hw5-Q@eHE(m2ux^e%VVLriat+pSL)7d`?Z-zD-}I<~Wf%rw zwe0b9PW;``VZ3QgF%9+uWgL|Ez#_<|{$3lpR-&+G7ZwA_ML$3j?iOujERw7KAa$cC zy@i^c&29WAcXyjd{be5nUFX+N87>Uc*Rc!E;u&s8Et+5ekI)1I7+I#Mi)L%)-iBXHNirei9a5X4jl@11w|C<4LxGKRtC0is} z$kTLAaBpIrBkm_w5c=B)T3zN&x4G5UTQ$m`2SI+c)Edj`|IT886{5iuBUXicPPjJ= zbRHkd-X!wem-wN%{CZ>itE}wnr*}8sNalS=9VpzRFDA87x98Yp4BJ+snII{1J!%z4WWM8=< z=pH|k>vu`7Ke9dR$Lt!bJpX;va|VzKW7WNg#2_`FFAWT+aNkApP2YiCn7P|D<~TH( z>)~K-T=FF;|D^QW7D?7hUf9+tmiG9tWF@!@YVSLmQj3;0kQTl3Y3=QP);6(3Gp zCim8~o3(771t%eA{0{XMM&#UmjQA7s)IICzt@}nh#{-_?E@&sY23!1m5eb`G-;LD+ zt&M+nGJeSubs`B+`mx%EG;d@7&Idv>&nCtX&Z0`rOGop#Ai)hg-n>(y zvyx(lxJ7X(pD#Vr!eP8$>TY*O@M(2L!Xpf$>{rc8<4kDZ!M^QvIneQFcl1d_;Di#jh3bpr zhr`d%uUu7?Ni!zxth_)9k|X(-C$#e4vY=?(rhkznSfC#xg_Bv1=iwB3aAxXH zm>WCaIQnWn7Gvv0`gD#miUV1AQ4)XgEg1Nw$TxSR(nW7~9wXkg`Z`4#t-`h2Hd_w9e=ek% zYb;@Pmc#X9?#-VD2o;JyeF&*ROG4jOIf)$6y`-d07whoB2hP~A;UB&-^^SO#aki!? zaQ)0bTkdY7wezX$y9w9nl_^zurUsGPs08RdjI{Oqz(n%P?SDF3;!E-e>^yIwaLM~l z3_yVIpx;biqNCIhr9BT6K$uUkE%+ero%fPJr&x|zGoS`sOHsKI83ePJG1C+Sr z>|aZQIkK$Zr}#d%8|cXWYkOic$nq6idrOj7cRJ?*5xTUN>@ReP8}Hf2_GkSg;c>Y< zpJmSBvsjMX!{L+ZdLO-#Rx-3hi?+*ab}67OG)V=uwgQf$@P~BVh9cFZ?nG_Vy!_{_ug>)QDxb@O=M7MdD!xJ7uUQOVly|0uE)iJWx}1`oZHOFt@in@k><@mJ5)nmN?tCD28%uufKv7N-2-XQajD1&@ z{(@Wa>#%w*@%q>sZPoe{M}i<@vvf`x<+2mZ!T!lbmQ3g}!Q@e7#m?@-tQ(&iYr0&u~!x{J7}9Wmx!B1Wx-eGf$jFZm`u??>9FM^7J` zOfS+^y`H|Ma2N3q#!b+zqcE9+qHivoo;G)B?pJ!LHcqC0+KWckw~xvdqq!lm5XLkZ zJ!Z(zhR}|)O&oth*^lK=7#~*yxBEl!X%S8LL zvl{gUIJQ^l+6quz!L$>$q!(SkmLg;l;9stF*M* zUbmuifk{Kw?s1UOMfIO25uB{S2vb6cY540~LFl`BYMMn-5}KF?KeG%!Grzzq@*J!o z*ud;`J?Sj1q@f6WB~NOBY$`(VjdqOR;G7|O*dWw9^0>v95M{F0&B_4jBeq?VQ!g&P zM8Q1?HHIc`KJhaLv}x(qi<1k>23OP_N_ye71-bOtu4A zaEWXKZUEs~$j>w~YsPbY^rs~uca?9BpXMyoNc^D0K~AfsY* zPD$`(Sh7CF_t!Us2(1MEUPq94WA1grT%fnHqQ!JTmrIx)czNQM<-IZZK;xpJNTD`G`scq6jxNL`E0kRYp5@? z^2Jl71eKwd!IL%pA1k{IY)dPDhFYL!Vi1MsP?&ng>_?*kqZ==R4jRO(NMOiFqhh{j zXzeh06gD9F2|~Uic|G?k|H_M>jK2!?mERVfMl15=qE~BWz&2-N5Z&@`NfG|Gq%U7K_S^JdN1G=8c zcxte%h9p@-fYtnFvK=zQ2))pcG+WNAhNeLQN`lIS_4`k-Rn@m$HNp12_607EXh z3zuJfIewWrss;l|MG+z(U8m%Xc(!PSyxY?^Gc^y|^FkLDno$Dv#Ph7T1=hB7`@OwbUAsM_6)JRjL_^s0eEA>h z*!sEzTC+oBUklFwK-oJ={`W_S4Dy-FYS|gv<$0$DUhEU`=uvu}|B8^Y_4f9V&CQ)o zkEieAuKNo363PVsM#WgcpiEpe)sogpm;2!pEkGp0yClR@@N`9D0G%H?+CZKJ12LI1 zyyYH-+r{%OU6CG1(wUHZaL(MN%X%+_Kcl)(F|jjLQm&d>fA~uW^m9}(EX2k5Pzn?k z3KyAu8Iu~Gf$bqRe+12Q6bVH(6t*NPzYS{(sQHbPBlz-WRgdfRJ;4ZYN>_=M%JKPC zMnE)>3#cZo-aq8?5PbYQs?Wq$*|B#cLYYe)q9?|#R=*Ta&9!~p_M+kISC2ej>^|S% z;G6ev&^D+pQ^?|L6c190%%exU4{@D!P?wycER8>G%(lpHDy%gx|J}G|e{uo}1hr@T@mjOufE&JV75!nw=laY3jvW zqrYckZP2g&kfo-#c;OTPIZ$BNLyMlX>c31m=F}UK4xN=Y7Xm&#v77@8q(oMpaB3V@ z7zk3c^H(*3qmpobZ9})V=>0?6SbH7y3k`xLf}S*_D;V(LoJallnKgw2@Z4kE2C9o6 zgI5ZOr8v`vqVK9?RGrPX?hEC9&vj2FY{HWS4sznj}Dwg;t+8jlGf|hzdt9RxJ?pcoM+8 zBz7JVOe=iU{76?WKqZJ{drKpZoq0u$_Jj6t|@qv8bT)sfSWNc|b zS)#^!B27Z!XWM3Pcd7FQCa+H6&WT_*TQ3zOj)>R}E+1x``}!e&!~P2!)H5l$-f10c zddr6tQ=N8|0c6*=XNdLS7ekLCr@3UqNYm{u?ODH@V$`Q9ULvlMLy0U)DyeX!1j6du zAF5W(`uobr@{Ncwtn(*lq3eA5s+H9%p5cQcw%n>58vS;=7r{uZg5A~*W3Uqf!B<;xy+oEG&&C1?f+ zB2+?&m2}Qb541qScq(F&B3hPBYjNwXwB2o+D3KDocJXorB*5{Som=!MP z8bxtTVhA?UE?L?deFHOb5Z!)`$T>OWjd!QES-X7L+!U+PCkQ3)!jdJ~=}HJ*q89ZG zOoXaZ8D-?Kos0UgX>0ZU_5t)s-90-_u`Pdb%?m+6eqSp$)+bVNWx`>YCvR_=XQxgU zfJV~$`{2=K4D#1gJ?U~F-Z*xvbI2VR21)=gyx@=y{hxiqx+CJ~)Hxboe9J%aPs_=A9M;O)m zaS%H_eXT6EL@>cNGTL@^s5v<^>2x~Ijn<7B}G%tdB!~lS>NC(gH@s$Z3{irr5cF%-m z@b_a2#K%Ghk(uk`Z66Y@z%wEKu-P_J1IUmxs#P6PYaqbI*$a0`V!`0s?ys8>nrBy? z=M8d-17*JF=u0O!CD}+hNXI5+{4%9;f}(h2jU3_FLxmiIC<(cih(q?*gAIDGX@OYVn?QC8m4kPc+<@xK)n4UwQl?s*OpH! zPvr()r54a*ssfQZ^kFoA)?v4E%+jCKhLA3qq~BpxH@**+iTS-v_Su&b)RvMrUJ(UN zet7~k9aT5IwMMz=Xx@5c~zqVLg4bnO<92b&hU8&15fuI*of%e(-pYKLE#rnH2_Pv0ZPr|n< zJTBa7Cb@RpjJ#GpQ1`VMEl&qeVZ4DgBxH@qc7tcZC8mYRc2-R-eLe{uqB# z6g~|vvQ4d--Zwcjutc_N+TkvTzVr$)ZPr2A{~#>qu5pIwlq>C%LTEDZPw_qANt?wv z*rP&`3DHG9n4KdPA)8))P7Rw+Os+O64$DjaV_mSw#_{{*Q= z^(@5in^x!+9yWkm5mJ4ik+BNscC{XL)(PyxeIG!YlON8=A z{>UAN#yM&U9r!j2E~@C9Lpy>g{g^qe0C7=KYe52edb3b5SCf0{FwA^C;wz+ehH~?} zZl%2Fw%YNtOOKs#ena(JKQF1Y8ON^GZ(R%4ROaVc_(pOd=`3sx7MMw%|30NBjQJhi7X_X-iIb!gU=3= zT0b^ShgWfSo+8_NvF$-m^b>B5u4S_&_4Ou-R}=Xz{Mw`aqL07`W;NgANrt?)sT}0+ zGT+M&|9LY}mO8hvxW@oYTU4R(za%9l_fIexPA(ojxH;YLd8=r0JNhW^FRWvkpKVy literal 2011 zcmV<12PF83P)D!y%g4vZ*x1<2%*?Q`u)x5;+S=OH)zz}HvdGBD zw6wIs!oteR%FWHqrlzLJ$;r^r(7(UGt*x!9s;a}o!>FjJ#>U3Iy}i%R&$YF+x3{;h zuCCtR-nO>3udlDMv9Z$9(zv*|ySuyA*4EqG+ZlWNivR!y*-1n}RCr$O+*gj>NDPM2 zpNco<96RTn9rwSi8F&oCZFlz#Qq{xgJ4+x4LZrx&UjP6A00000000000000000000 z0000000000h)13Iv~gIg9X6)(&Ma2$kko6>k^LchuGJIe1?9VT%ih&$-<6da;(GtY zRygU`V`YKSGPJdX%aOK)>O)}bd#Gv~*uA$6-S28s*y-CA`a9YL;;WKvrF0c*ZKyul z)*h=`7wH^W2Elqsjqd}?Xz*Ptp%PkVVMQxo5?Bs`i9Upe<)onxU}?Eo>Ro7Cj@nv* ze0H1D=vvOYdIRQ`ySe^?YRPg}s_G9&F3o|+E;KJf72K|Qva4ddGB4O%8ki>o6?HI`i*6#+w#5|Y?<y|LwN+|wJ-DOs*c9lZ(lz;YDS^$r~880p|x z??Sy~IVsik0el`?F2Y>=KFtcZ1R zaGELyc&axa?H!NJ`jhg5v~%-y@6vvAx%c&^lgbGsy=v!Q)n20Y000000000000000 z0000001(G=0_9$3(l{U9f4Ydqf&CDSql-`X!}G?Z(<>_@q?M!EmqlnRgo`h=qe`mH zp^F!L#dKMP=0-j66q)X!m2_UKU< z?=gcl`PrjD-Z_UQ!Oo*l-Y$R@ac4on7M&O-j9L_~HG+QG8x@#l(9NwQst~Om6uTU# z#^D0B?jkA@-9e`x4^-yi2NWuOs^gbXXX{ZRZwp;qmr8Y=vc4+;?DuSiV=6Wdp+vB! zYWn~xgnKHt51~MOqTGjPD@PoaW3S(TV1qAdpLGPU;{}n+CNZ&z1&l zAk)yBhI<>K$Szko#25&!NK0f~qZEphQ8{ z3aIf)#a^Mvnrf|~$}^REhB95M)P*|TX3iCPq&kn#$aR|+5*6uFl|Ix;s7M0Ewp3#a z)kYpw@J3LsOsPOxLcOd<@p>5y2p=fiLkJUGpQ800tayqjR`dizEyTFw96Gb|` z!YX_?aV~Q~zz%VD}8+~-=U|TkA>Od+oZ}G}nbJRjS zi+DxREJj@nPrRDb5Owl07n$q)ia(q8)f$NXg*KhY?6EB-U9U5WqIxO65S z|C%@HT<{BxaK1b6FYI^o5WlAp7RhA6Kem`8MTkGt$mX+;7JpOgV>ZtqfL8IK*!Dm6 zzQ>>P{QXCNTMXj=+Nnb$0D%|)f;hy>mx(1hlm3f?BA8dZ4V=5bdfd6DDtbSk-BC3w tPksUb00000000000000000000fEW70#Mag`cL)Fg002ovPDHLkV1gNB=am2e From 2289106ac7cac9e1fad9aa60aaa1163b0f80613f Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Fri, 31 Mar 2023 14:17:08 +0200 Subject: [PATCH 111/177] Fix last seen not set error on admin user page --- app/templates/admin/user.html.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/templates/admin/user.html.j2 b/app/templates/admin/user.html.j2 index 0512db75..7ecc6f19 100644 --- a/app/templates/admin/user.html.j2 +++ b/app/templates/admin/user.html.j2 @@ -41,7 +41,7 @@
            • Id: {{ user.id }}
            • Hashid: {{ user.hashid }}
            • Member since: {{ user.member_since.strftime('%Y-%m-%d') }}
            • -
            • Last seen: {{ user.last_seen.strftime('%Y-%m-%d') }}
            • +
            • Last seen: {% if user.last_seen %}{{ user.last_seen.strftime('%Y-%m-%d') }}
            • {% endif %}
              From 4d2d4fcc40b28eaf188199cc2ebb0507c355539b Mon Sep 17 00:00:00 2001 From: Inga Kirschnick Date: Fri, 31 Mar 2023 14:28:11 +0200 Subject: [PATCH 112/177] Add followed corpora to Corpus List --- app/static/js/ResourceLists/CorpusList.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/static/js/ResourceLists/CorpusList.js b/app/static/js/ResourceLists/CorpusList.js index 00ddd7c1..a6207baf 100644 --- a/app/static/js/ResourceLists/CorpusList.js +++ b/app/static/js/ResourceLists/CorpusList.js @@ -17,7 +17,12 @@ class CorpusList extends ResourceList { }); }); app.getUser(this.userId).then((user) => { + let followedCorpora = []; + for (let cfa of Object.values(user.corpus_follower_associations)) { + followedCorpora.push(cfa.corpus); + } this.add(Object.values(user.corpora)); + this.add(followedCorpora); this.isInitialized = true; }); } From f1d8b81923966ab35ba1ab5deea3d381cb9cebf7 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Mon, 3 Apr 2023 15:25:55 +0200 Subject: [PATCH 113/177] move settings related json routes to users package --- app/admin/routes.py | 4 +- app/settings/__init__.py | 3 +- app/settings/utils.py | 6 --- app/static/js/Requests/settings/settings.js | 45 --------------------- app/static/js/Requests/users/users.js | 25 +++++++++++- app/templates/_scripts.html.j2 | 1 - app/templates/settings/settings.html.j2 | 12 +++--- app/users/__init__.py | 2 +- app/users/settings/__init__.py | 2 + app/{ => users}/settings/json_routes.py | 21 +++++----- 10 files changed, 47 insertions(+), 74 deletions(-) delete mode 100644 app/settings/utils.py delete mode 100644 app/static/js/Requests/settings/settings.js create mode 100644 app/users/settings/__init__.py rename app/{ => users}/settings/json_routes.py (79%) diff --git a/app/admin/routes.py b/app/admin/routes.py index 9a5401b8..3822dc28 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -65,10 +65,10 @@ def user_settings(user_id): user = User.query.get_or_404(user_id) update_account_information_form = UpdateAccountInformationForm(user=user) update_profile_information_form = UpdateProfileInformationForm(user=user) - update_avatar_form = UpdateAvatarForm(user=user) + update_avatar_form = UpdateAvatarForm() update_password_form = UpdatePasswordForm(user=user) update_notifications_form = UpdateNotificationsForm(user=user) - update_user_form = UpdateUserForm(user) + update_user_form = UpdateUserForm(user=user) # region handle update profile information form if update_profile_information_form.submit.data and update_profile_information_form.validate(): diff --git a/app/settings/__init__.py b/app/settings/__init__.py index aaa1cb1b..56265277 100644 --- a/app/settings/__init__.py +++ b/app/settings/__init__.py @@ -2,5 +2,4 @@ from flask import Blueprint bp = Blueprint('settings', __name__) -from . import routes, json_routes - +from . import routes diff --git a/app/settings/utils.py b/app/settings/utils.py deleted file mode 100644 index 53572740..00000000 --- a/app/settings/utils.py +++ /dev/null @@ -1,6 +0,0 @@ -from flask import request, url_for -from app.models import User - - -def user_endpoint_arguments_constructor(): - return {'user_id': request.view_args['user_id']} diff --git a/app/static/js/Requests/settings/settings.js b/app/static/js/Requests/settings/settings.js deleted file mode 100644 index d3137267..00000000 --- a/app/static/js/Requests/settings/settings.js +++ /dev/null @@ -1,45 +0,0 @@ -/***************************************************************************** -* Settings * -* Fetch requests for /settings routes * -*****************************************************************************/ -Requests.settings = {}; - -Requests.settings.entity = {}; - -Requests.settings.entity.delete = (userId) => { - let input = `/settings/${userId}`; - let init = { - method: 'DELETE' - }; - return Requests.JSONfetch(input, init); -} - -Requests.settings.entity.deleteAvatar = (userId) => { - let input = `/settings/${userId}/avatar`; - let init = { - method: 'DELETE' - }; - return Requests.JSONfetch(input, init); -} - -Requests.settings.entity.isPublic = {}; - -Requests.settings.entity.isPublic.update = (userId, isPublic) => { - let input = `/settings/${userId}/is-public`; - let init = { - method: 'PUT', - body: JSON.stringify(isPublic) - }; - return Requests.JSONfetch(input, init); -}; - -Requests.settings.entity.profilePrivacySettings = {}; - -Requests.settings.entity.profilePrivacySettings.update = (userId, profilePrivacySetting, enabled) => { - let input = `/settings/${userId}/profile-privacy-settings/${profilePrivacySetting}`; - let init = { - method: 'PUT', - body: JSON.stringify(enabled) - }; - return Requests.JSONfetch(input, init); -} diff --git a/app/static/js/Requests/users/users.js b/app/static/js/Requests/users/users.js index 00adbee9..9ed77cb0 100644 --- a/app/static/js/Requests/users/users.js +++ b/app/static/js/Requests/users/users.js @@ -6,10 +6,33 @@ Requests.users = {}; Requests.users.entity = {}; -Requests.settings.entity.delete = (userId) => { +Requests.users.entity.delete = (userId) => { let input = `/users/${userId}`; let init = { method: 'DELETE' }; return Requests.JSONfetch(input, init); }; + +Requests.users.entity.settings = {}; + +Requests.users.entity.settings.avatar = {}; + +Requests.users.entity.settings.avatar.delete = (userId) => { + let input = `/users/${userId}/settings/avatar`; + let init = { + method: 'DELETE' + }; + return Requests.JSONfetch(input, init); +} + +Requests.users.entity.settings.profilePrivacy = {}; + +Requests.users.entity.settings.profilePrivacy.update = (userId, profilePrivacySetting, enabled) => { + let input = `/users/${userId}/settings/profile-privacy/${profilePrivacySetting}`; + let init = { + method: 'PUT', + body: JSON.stringify(enabled) + }; + return Requests.JSONfetch(input, init); +} diff --git a/app/templates/_scripts.html.j2 b/app/templates/_scripts.html.j2 index 089a7cd2..dbc26470 100644 --- a/app/templates/_scripts.html.j2 +++ b/app/templates/_scripts.html.j2 @@ -66,7 +66,6 @@ 'js/Requests/corpora/files.js', 'js/Requests/corpora/followers.js', 'js/Requests/jobs/jobs.js', - 'js/Requests/settings/settings.js', 'js/Requests/users/users.js' %} diff --git a/app/templates/settings/settings.html.j2 b/app/templates/settings/settings.html.j2 index 27c9590c..b267dd44 100644 --- a/app/templates/settings/settings.html.j2 +++ b/app/templates/settings/settings.html.j2 @@ -212,7 +212,7 @@ avatarUploadElement.addEventListener('change', () => { }); deleteAvatarButtonElement.addEventListener('click', () => { - Requests.settings.entity.deleteAvatar({{ user.hashid|tojson }}) + Requests.users.entity.settings.avatar.delete({{ user.hashid|tojson }}) .then( (response) => { avatarPreviewElement.src = {{ url_for('static', filename='images/user_avatar.png')|tojson }}; @@ -245,16 +245,16 @@ for (let collapsibleElement of document.querySelectorAll('.collapsible.no-autoin let profileIsPublicSwitchElement = document.querySelector('#profile-is-public-switch'); let profilePrivacySettingCheckboxElements = document.querySelectorAll('.profile-privacy-setting-checkbox'); profileIsPublicSwitchElement.addEventListener('change', (event) => { - let newIsPublic = profileIsPublicSwitchElement.checked; - Requests.settings.entity.isPublic.update({{ user.hashid|tojson }}, newIsPublic) + let newEnabled = profileIsPublicSwitchElement.checked; + Requests.users.entity.settings.profilePrivacy.update({{ user.hashid|tojson }}, 'is-public', newEnabled) .then( (response) => { for (let profilePrivacySettingCheckboxElement of document.querySelectorAll('.profile-privacy-setting-checkbox')) { - profilePrivacySettingCheckboxElement.disabled = !newIsPublic; + profilePrivacySettingCheckboxElement.disabled = !newEnabled; } }, (response) => { - profileIsPublicSwitchElement.checked = !newIsPublic; + profileIsPublicSwitchElement.checked = !newEnabled; } ); }); @@ -262,7 +262,7 @@ for (let profilePrivacySettingCheckboxElement of profilePrivacySettingCheckboxEl profilePrivacySettingCheckboxElement.addEventListener('change', (event) => { let newEnabled = profilePrivacySettingCheckboxElement.checked; let valueName = profilePrivacySettingCheckboxElement.dataset.profilePrivacySettingName; - Requests.settings.entity.profilePrivacySettings.update({{ user.hashid|tojson }}, valueName, newEnabled) + Requests.users.entity.settings.profilePrivacy.update({{ user.hashid|tojson }}, valueName, newEnabled) .catch((response) => { profilePrivacySettingCheckboxElement.checked = !newEnabled; }); diff --git a/app/users/__init__.py b/app/users/__init__.py index ecd6b9da..11a38f25 100644 --- a/app/users/__init__.py +++ b/app/users/__init__.py @@ -3,4 +3,4 @@ from flask import Blueprint bp = Blueprint('users', __name__) from . import events, routes - +from . import settings diff --git a/app/users/settings/__init__.py b/app/users/settings/__init__.py new file mode 100644 index 00000000..1dbe44f0 --- /dev/null +++ b/app/users/settings/__init__.py @@ -0,0 +1,2 @@ +from .. import bp +from . import json_routes diff --git a/app/settings/json_routes.py b/app/users/settings/json_routes.py similarity index 79% rename from app/settings/json_routes.py rename to app/users/settings/json_routes.py index 31002d26..acc7dbc9 100644 --- a/app/settings/json_routes.py +++ b/app/users/settings/json_routes.py @@ -1,5 +1,5 @@ from flask import abort, current_app, request -from flask_login import current_user, login_required, logout_user +from flask_login import login_required from threading import Thread import os from app import db @@ -8,14 +8,15 @@ from app.models import Avatar, User, ProfilePrivacySettings from . import bp -@bp.route('//avatar', methods=['DELETE']) +@bp.route('//settings/avatar', methods=['DELETE']) @content_negotiation(produces='application/json') -def delete_profile_avatar(user_id): +def delete_user_avatar(user_id): def _delete_avatar(app, avatar_id): with app.app_context(): avatar = Avatar.query.get(avatar_id) avatar.delete() db.session.commit() + user = User.query.get_or_404(user_id) if user.avatar is None: abort(404) @@ -30,27 +31,27 @@ def delete_profile_avatar(user_id): return response_data, 202 -@bp.route('//is-public', methods=['PUT']) +@bp.route('//settings/profile-privacy/is-public', methods=['PUT']) @login_required @content_negotiation(consumes='application/json', produces='application/json') -def update_user_is_public(user_id): +def update_user_profile_privacy_setting_is_public(user_id): + user = User.query.get_or_404(user_id) is_public = request.json if not isinstance(is_public, bool): abort(400) - user = User.query.get_or_404(user_id) user.is_public = is_public db.session.commit() response_data = { 'message': 'Profile privacy settings updated', - 'category': 'corpus' + 'category': 'settings' } return response_data, 200 -@bp.route('//profile-privacy-settings/', methods=['PUT']) +@bp.route('//settings/profile-privacy/', methods=['PUT']) @login_required @content_negotiation(consumes='application/json', produces='application/json') -def add_profile_privacy_settings(user_id, profile_privacy_setting_name): +def update_user_profile_privacy_settings(user_id, profile_privacy_setting_name): user = User.query.get_or_404(user_id) enabled = request.json if not isinstance(enabled, bool): @@ -66,6 +67,6 @@ def add_profile_privacy_settings(user_id, profile_privacy_setting_name): db.session.commit() response_data = { 'message': 'Profile privacy settings updated', - 'category': 'corpus' + 'category': 'settings' } return response_data, 200 From 87798f47813f8ddeb9fe9154274810df986b56ba Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Mon, 3 Apr 2023 16:34:03 +0200 Subject: [PATCH 114/177] completly move settings logic to users package --- app/admin/routes.py | 12 +-- app/settings/routes.py | 88 ++---------------- .../{ => users}/settings/settings.html.j2 | 0 app/users/settings/__init__.py | 2 +- app/{ => users}/settings/forms.py | 8 +- app/users/settings/json_routes.py | 20 ++-- app/users/settings/routes.py | 93 +++++++++++++++++++ 7 files changed, 124 insertions(+), 99 deletions(-) rename app/templates/{ => users}/settings/settings.html.j2 (100%) rename app/{ => users}/settings/forms.py (94%) create mode 100644 app/users/settings/routes.py diff --git a/app/admin/routes.py b/app/admin/routes.py index 3822dc28..67739edc 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -2,7 +2,7 @@ from flask import abort, flash, redirect, render_template, url_for from flask_breadcrumbs import register_breadcrumb from app import db, hashids from app.models import Avatar, Corpus, Role, User -from app.settings.forms import ( +from app.users.settings.forms import ( UpdateAvatarForm, UpdatePasswordForm, UpdateNotificationsForm, @@ -63,12 +63,12 @@ def user(user_id): @register_breadcrumb(bp, '.users.entity.settings', 'settingsSettings') def user_settings(user_id): user = User.query.get_or_404(user_id) - update_account_information_form = UpdateAccountInformationForm(user=user) - update_profile_information_form = UpdateProfileInformationForm(user=user) + update_account_information_form = UpdateAccountInformationForm(user) + update_profile_information_form = UpdateProfileInformationForm(user) update_avatar_form = UpdateAvatarForm() - update_password_form = UpdatePasswordForm(user=user) - update_notifications_form = UpdateNotificationsForm(user=user) - update_user_form = UpdateUserForm(user=user) + 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(): diff --git a/app/settings/routes.py b/app/settings/routes.py index 70b69d6c..9496e6fd 100644 --- a/app/settings/routes.py +++ b/app/settings/routes.py @@ -1,88 +1,14 @@ -from flask import abort, flash, redirect, render_template, url_for +from flask import url_for from flask_breadcrumbs import register_breadcrumb -from flask_login import current_user, login_required -from app import db -from app.models import Avatar +from flask_login import current_user +from app.users.settings.routes import settings as settings_route from . import bp -from .forms import ( - UpdateAvatarForm, - UpdatePasswordForm, - UpdateNotificationsForm, - UpdateAccountInformationForm, - UpdateProfileInformationForm -) -@bp.route('', methods=['GET', 'POST']) +@bp.route('/settings', methods=['GET', 'POST']) @register_breadcrumb(bp, '.', 'settingsSettings') -@login_required def settings(): - user = current_user - update_account_information_form = UpdateAccountInformationForm() - update_profile_information_form = UpdateProfileInformationForm() - update_avatar_form = UpdateAvatarForm() - update_password_form = UpdatePasswordForm() - update_notifications_form = UpdateNotificationsForm() - - # 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('.settings')) - # 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('.settings')) - # 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('.settings')) - # 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('.settings')) - # 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('.settings')) - # endregion handle update notifications form - - return render_template( - 'settings/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, - user=user + return settings_route( + current_user.id, + redirect_location_on_post=url_for('.settings') ) diff --git a/app/templates/settings/settings.html.j2 b/app/templates/users/settings/settings.html.j2 similarity index 100% rename from app/templates/settings/settings.html.j2 rename to app/templates/users/settings/settings.html.j2 diff --git a/app/users/settings/__init__.py b/app/users/settings/__init__.py index 1dbe44f0..e06bada9 100644 --- a/app/users/settings/__init__.py +++ b/app/users/settings/__init__.py @@ -1,2 +1,2 @@ from .. import bp -from . import json_routes +from . import json_routes, routes diff --git a/app/settings/forms.py b/app/users/settings/forms.py similarity index 94% rename from app/settings/forms.py rename to app/users/settings/forms.py index 77c8687c..71c29456 100644 --- a/app/settings/forms.py +++ b/app/users/settings/forms.py @@ -41,7 +41,7 @@ class UpdateAccountInformationForm(FlaskForm): ) submit = SubmitField() - def __init__(self, *args, user=current_user, **kwargs): + def __init__(self, user, *args, **kwargs): if 'data' not in kwargs: kwargs['data'] = user.to_json_serializeable() if 'prefix' not in kwargs: @@ -91,7 +91,7 @@ class UpdateProfileInformationForm(FlaskForm): ) submit = SubmitField() - def __init__(self, *args, user=current_user, **kwargs): + def __init__(self, user, *args, **kwargs): if 'data' not in kwargs: kwargs['data'] = user.to_json_serializeable() if 'prefix' not in kwargs: @@ -132,7 +132,7 @@ class UpdatePasswordForm(FlaskForm): ) submit = SubmitField() - def __init__(self, *args, user=current_user, **kwargs): + def __init__(self, user, *args, **kwargs): if 'prefix' not in kwargs: kwargs['prefix'] = 'update-password-form' super().__init__(*args, **kwargs) @@ -154,7 +154,7 @@ class UpdateNotificationsForm(FlaskForm): ) submit = SubmitField() - def __init__(self, *args, user=current_user, **kwargs): + def __init__(self, user, *args, **kwargs): if 'data' not in kwargs: kwargs['data'] = user.to_json_serializeable() if 'prefix' not in kwargs: diff --git a/app/users/settings/json_routes.py b/app/users/settings/json_routes.py index acc7dbc9..7fdfa7fc 100644 --- a/app/users/settings/json_routes.py +++ b/app/users/settings/json_routes.py @@ -1,5 +1,5 @@ from flask import abort, current_app, request -from flask_login import login_required +from flask_login import current_user, login_required from threading import Thread import os from app import db @@ -20,6 +20,8 @@ def delete_user_avatar(user_id): user = User.query.get_or_404(user_id) if user.avatar is None: abort(404) + if not (user == current_user or current_user.is_administrator()): + abort(403) thread = Thread( target=_delete_avatar, args=(current_app._get_current_object(), user.avatar.id) @@ -36,10 +38,12 @@ def delete_user_avatar(user_id): @content_negotiation(consumes='application/json', produces='application/json') def update_user_profile_privacy_setting_is_public(user_id): user = User.query.get_or_404(user_id) - is_public = request.json - if not isinstance(is_public, bool): + if not (user == current_user or current_user.is_administrator()): + abort(403) + enabled = request.json + if not isinstance(enabled, bool): abort(400) - user.is_public = is_public + user.is_public = enabled db.session.commit() response_data = { 'message': 'Profile privacy settings updated', @@ -53,13 +57,15 @@ def update_user_profile_privacy_setting_is_public(user_id): @content_negotiation(consumes='application/json', produces='application/json') def update_user_profile_privacy_settings(user_id, profile_privacy_setting_name): user = User.query.get_or_404(user_id) - enabled = request.json - if not isinstance(enabled, bool): - abort(400) try: profile_privacy_setting = ProfilePrivacySettings[profile_privacy_setting_name] except KeyError: abort(404) + if not (user == current_user or current_user.is_administrator()): + abort(403) + enabled = request.json + if not isinstance(enabled, bool): + abort(400) if enabled: user.add_profile_privacy_setting(profile_privacy_setting) else: diff --git a/app/users/settings/routes.py b/app/users/settings/routes.py new file mode 100644 index 00000000..f023bf00 --- /dev/null +++ b/app/users/settings/routes.py @@ -0,0 +1,93 @@ +from flask import abort, flash, redirect, render_template, url_for +from flask_breadcrumbs import register_breadcrumb +from flask_login import current_user, login_required +from app import db +from app.models import Avatar, User +from ..utils import user_endpoint_arguments_constructor as user_eac +from . import bp +from .forms import ( + UpdateAvatarForm, + UpdatePasswordForm, + UpdateNotificationsForm, + UpdateAccountInformationForm, + UpdateProfileInformationForm +) + + +@bp.route('//settings', methods=['GET', 'POST']) +@register_breadcrumb(bp, '.entity.settings', 'settingsSettings', endpoint_arguments_constructor=user_eac) +@login_required +def settings(user_id, redirect_location_on_post=None): + user = User.query.get_or_404(user_id) + if not (user == current_user or current_user.is_administrator()): + abort(403) + if redirect_location_on_post is None: + redirect_location_on_post = url_for('.settings', user_id=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) + + # 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(redirect_location_on_post) + # 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(redirect_location_on_post) + # 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(redirect_location_on_post) + # 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(redirect_location_on_post) + # 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(redirect_location_on_post) + # endregion handle update notifications form + + return render_template( + 'users/settings/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, + user=user + ) From a27caaa8a2f91d3548c24c9c2e2b14bff5dcccfe Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Tue, 4 Apr 2023 08:44:25 +0200 Subject: [PATCH 115/177] Use a better redirect mechanic in the proxied settings route --- app/settings/routes.py | 5 +---- app/templates/admin/user_settings.html.j2 | 2 ++ app/templates/users/settings/settings.html.j2 | 2 ++ app/users/settings/routes.py | 12 ++++++++---- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/app/settings/routes.py b/app/settings/routes.py index 9496e6fd..046f2f8c 100644 --- a/app/settings/routes.py +++ b/app/settings/routes.py @@ -8,7 +8,4 @@ from . import bp @bp.route('/settings', methods=['GET', 'POST']) @register_breadcrumb(bp, '.', 'settingsSettings') def settings(): - return settings_route( - current_user.id, - redirect_location_on_post=url_for('.settings') - ) + return settings_route(current_user.id) diff --git a/app/templates/admin/user_settings.html.j2 b/app/templates/admin/user_settings.html.j2 index 66d6250e..0d574d0e 100644 --- a/app/templates/admin/user_settings.html.j2 +++ b/app/templates/admin/user_settings.html.j2 @@ -1,6 +1,8 @@ {% extends "settings/settings.html.j2" %} {% block admin_settings %} +
              +

              Administrator Settings

              Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam diff --git a/app/templates/users/settings/settings.html.j2 b/app/templates/users/settings/settings.html.j2 index b267dd44..d52c989b 100644 --- a/app/templates/users/settings/settings.html.j2 +++ b/app/templates/users/settings/settings.html.j2 @@ -102,6 +102,8 @@

              +
              +

              General Settings

              Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam diff --git a/app/users/settings/routes.py b/app/users/settings/routes.py index f023bf00..13f222b7 100644 --- a/app/users/settings/routes.py +++ b/app/users/settings/routes.py @@ -1,4 +1,4 @@ -from flask import abort, flash, redirect, render_template, url_for +from flask import abort, flash, g, redirect, render_template, url_for from flask_breadcrumbs import register_breadcrumb from flask_login import current_user, login_required from app import db @@ -17,12 +17,16 @@ from .forms import ( @bp.route('//settings', methods=['GET', 'POST']) @register_breadcrumb(bp, '.entity.settings', 'settingsSettings', endpoint_arguments_constructor=user_eac) @login_required -def settings(user_id, redirect_location_on_post=None): +def settings(user_id): user = User.query.get_or_404(user_id) if not (user == current_user or current_user.is_administrator()): abort(403) - if redirect_location_on_post is None: - redirect_location_on_post = url_for('.settings', user_id=user_id) + + redirect_location_on_post = g.pop( + 'redirect_location_on_post', + url_for('.settings', user_id=user_id) + ) + update_account_information_form = UpdateAccountInformationForm(user) update_profile_information_form = UpdateProfileInformationForm(user) update_avatar_form = UpdateAvatarForm() From 423709b4eb9d086010efb94a8e3a9b6cfc7be0e6 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Tue, 4 Apr 2023 08:56:19 +0200 Subject: [PATCH 116/177] Add a prefix for nopaque's data within the application context --- app/settings/routes.py | 3 ++- app/users/settings/routes.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/settings/routes.py b/app/settings/routes.py index 046f2f8c..837c0f6f 100644 --- a/app/settings/routes.py +++ b/app/settings/routes.py @@ -1,4 +1,4 @@ -from flask import url_for +from flask import g, url_for from flask_breadcrumbs import register_breadcrumb from flask_login import current_user from app.users.settings.routes import settings as settings_route @@ -8,4 +8,5 @@ from . import bp @bp.route('/settings', methods=['GET', 'POST']) @register_breadcrumb(bp, '.', 'settingsSettings') def settings(): + g._nopaque_redirect_location_on_post = url_for('.settings') return settings_route(current_user.id) diff --git a/app/users/settings/routes.py b/app/users/settings/routes.py index 13f222b7..d921c5c4 100644 --- a/app/users/settings/routes.py +++ b/app/users/settings/routes.py @@ -23,7 +23,7 @@ def settings(user_id): abort(403) redirect_location_on_post = g.pop( - 'redirect_location_on_post', + '_nopaque_redirect_location_on_post', url_for('.settings', user_id=user_id) ) From ee82dafb7ca9eb0fb0613f2529bf4581871340f2 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Tue, 4 Apr 2023 09:01:51 +0200 Subject: [PATCH 117/177] Fix admin user settings template --- app/templates/admin/user_settings.html.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/templates/admin/user_settings.html.j2 b/app/templates/admin/user_settings.html.j2 index 0d574d0e..527e96d2 100644 --- a/app/templates/admin/user_settings.html.j2 +++ b/app/templates/admin/user_settings.html.j2 @@ -1,4 +1,4 @@ -{% extends "settings/settings.html.j2" %} +{% extends "users/settings/settings.html.j2" %} {% block admin_settings %}

              From 477e583be9d2942e6422c975a441ad52074cd841 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Tue, 4 Apr 2023 09:14:32 +0200 Subject: [PATCH 118/177] fix settings page delete user function --- app/templates/users/settings/settings.html.j2 | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/templates/users/settings/settings.html.j2 b/app/templates/users/settings/settings.html.j2 index d52c989b..91b2864d 100644 --- a/app/templates/users/settings/settings.html.j2 +++ b/app/templates/users/settings/settings.html.j2 @@ -125,7 +125,7 @@ {{ wtf.render_field(update_account_information_form.username, material_icon='person') }} {{ wtf.render_field(update_account_information_form.email, material_icon='email') }}
              - deleteDelete + deleteDelete {{ wtf.render_field(update_account_information_form.submit, material_icon='send') }}
              @@ -184,7 +184,7 @@
              - diff --git a/app/templates/admin/user_settings.html.j2 b/app/templates/admin/user_settings.html.j2 index 48391a71..fb022564 100644 --- a/app/templates/admin/user_settings.html.j2 +++ b/app/templates/admin/user_settings.html.j2 @@ -5,10 +5,7 @@

              Administrator Settings

              -

              Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam - nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, - sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. - Stet clita kasd gubergren, no sea tak

              +

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


              diff --git a/app/templates/corpora/analyse_corpus.reader.html.j2 b/app/templates/corpora/analyse_corpus.reader.html.j2 index 99cdf56e..a4ca22fb 100644 --- a/app/templates/corpora/analyse_corpus.reader.html.j2 +++ b/app/templates/corpora/analyse_corpus.reader.html.j2 @@ -6,7 +6,7 @@
              -
              +
              format_list_numbered
              -
              +
              format_shapes
              -
              +
              format_quote - help Corpus Query Language tutorial + help Corpus Query Language tutorial | - info Tagsets + info Tagsets | info Examples
              diff --git a/app/templates/corpora/analyse_corpus.reader.html.j2 b/app/templates/corpora/_analysis/reader.html.j2 similarity index 100% rename from app/templates/corpora/analyse_corpus.reader.html.j2 rename to app/templates/corpora/_analysis/reader.html.j2 diff --git a/app/templates/corpora/analyse_corpus.html.j2 b/app/templates/corpora/analysis.html.j2 similarity index 65% rename from app/templates/corpora/analyse_corpus.html.j2 rename to app/templates/corpora/analysis.html.j2 index 7d9debe1..dcb42b9f 100644 --- a/app/templates/corpora/analyse_corpus.html.j2 +++ b/app/templates/corpora/analysis.html.j2 @@ -32,216 +32,25 @@
              -{% include "corpora/analyse_corpus.reader.html.j2" %} -{% include "corpora/analyse_corpus.concordance.html.j2" %} + +{# #} +{% include "corpora/_analysis/reader.html.j2" %} + +{% include "corpora/_analysis/concordance.html.j2" %} {% endblock page_content %} {% block modals %} {{ super() }}