From cda28910f5987c98a072d723c412aeda634184a1 Mon Sep 17 00:00:00 2001
From: Patrick Jentsch
Date: Mon, 16 Dec 2024 10:07:21 +0100
Subject: [PATCH] Update settings
---
app/__init__.py | 3 -
app/blueprints/settings/__init__.py | 11 --
app/blueprints/{users => }/settings/forms.py | 12 +-
app/blueprints/settings/routes.py | 162 +++++++++++++++++-
app/blueprints/users/__init__.py | 13 +-
app/blueprints/users/events.py | 58 +++++++
app/blueprints/users/json_routes.py | 69 --------
app/blueprints/users/routes.py | 127 ++++++++++++--
app/blueprints/users/settings/__init__.py | 2 -
app/blueprints/users/settings/json_routes.py | 49 ------
app/blueprints/users/settings/routes.py | 93 ----------
app/models/event_listeners.py | 15 +-
app/namespaces/users.py | 128 --------------
app/static/js/app/client.js | 1 +
app/static/js/app/endpoints/settings.js | 77 +++++++++
app/static/js/app/endpoints/users.js | 45 +++--
app/static/js/app/extensions/user-hub.js | 2 +-
app/templates/_base/dropdowns.html.j2 | 4 +-
app/templates/_base/scripts.html.j2 | 1 +
app/templates/_base/sidenav.html.j2 | 4 +-
.../index.html.j2} | 46 ++---
21 files changed, 476 insertions(+), 446 deletions(-)
rename app/blueprints/{users => }/settings/forms.py (95%)
create mode 100644 app/blueprints/users/events.py
delete mode 100644 app/blueprints/users/json_routes.py
delete mode 100644 app/blueprints/users/settings/__init__.py
delete mode 100644 app/blueprints/users/settings/json_routes.py
delete mode 100644 app/blueprints/users/settings/routes.py
delete mode 100644 app/namespaces/users.py
create mode 100644 app/static/js/app/endpoints/settings.js
rename app/templates/{users/settings/settings.html.j2 => settings/index.html.j2} (89%)
diff --git a/app/__init__.py b/app/__init__.py
index b555c0bc..7272fab3 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -135,9 +135,6 @@ def create_app(config: Config = Config) -> Flask:
from .namespaces.corpora import CorporaNamespace
socketio.on_namespace(CorporaNamespace('/corpora'))
-
- from .namespaces.users import UsersNamespace
- socketio.on_namespace(UsersNamespace('/users'))
# endregion SocketIO Namespaces
# region Database event Listeners
diff --git a/app/blueprints/settings/__init__.py b/app/blueprints/settings/__init__.py
index ee16889f..d1366879 100644
--- a/app/blueprints/settings/__init__.py
+++ b/app/blueprints/settings/__init__.py
@@ -1,18 +1,7 @@
from flask import Blueprint
-from flask_login import login_required
bp = Blueprint('settings', __name__)
-@bp.before_request
-@login_required
-def before_request():
- '''
- Ensures that the routes in this package can only be visited by users that
- are logged in.
- '''
- pass
-
-
from . import routes
diff --git a/app/blueprints/users/settings/forms.py b/app/blueprints/settings/forms.py
similarity index 95%
rename from app/blueprints/users/settings/forms.py
rename to app/blueprints/settings/forms.py
index 2e824d49..79b76e6f 100644
--- a/app/blueprints/users/settings/forms.py
+++ b/app/blueprints/settings/forms.py
@@ -38,8 +38,8 @@ class UpdateAccountInformationForm(FlaskForm):
]
)
submit = SubmitField()
-
- def __init__(self, user, *args, **kwargs):
+
+ def __init__(self, user: User, *args, **kwargs):
if 'data' not in kwargs:
kwargs['data'] = user.to_json_serializeable()
if 'prefix' not in kwargs:
@@ -64,7 +64,7 @@ class UpdateProfileInformationForm(FlaskForm):
validators=[Length(max=128)]
)
about_me = TextAreaField(
- 'About me',
+ 'About me',
validators=[
Length(max=254)
]
@@ -89,7 +89,7 @@ class UpdateProfileInformationForm(FlaskForm):
)
submit = SubmitField()
- def __init__(self, user, *args, **kwargs):
+ def __init__(self, user: User, *args, **kwargs):
if 'data' not in kwargs:
kwargs['data'] = user.to_json_serializeable()
if 'prefix' not in kwargs:
@@ -130,7 +130,7 @@ class UpdatePasswordForm(FlaskForm):
)
submit = SubmitField()
- def __init__(self, user, *args, **kwargs):
+ def __init__(self, user: User, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'update-password-form'
super().__init__(*args, **kwargs)
@@ -152,7 +152,7 @@ class UpdateNotificationsForm(FlaskForm):
)
submit = SubmitField()
- def __init__(self, user, *args, **kwargs):
+ def __init__(self, user: User, *args, **kwargs):
if 'data' not in kwargs:
kwargs['data'] = user.to_json_serializeable()
if 'prefix' not in kwargs:
diff --git a/app/blueprints/settings/routes.py b/app/blueprints/settings/routes.py
index 9bc1c99e..6a6c664e 100644
--- a/app/blueprints/settings/routes.py
+++ b/app/blueprints/settings/routes.py
@@ -1,10 +1,158 @@
-from flask import g, url_for
-from flask_login import current_user
-from app.blueprints.users.settings.routes import settings as settings_route
+from flask import (
+ abort,
+ flash,
+ jsonify,
+ redirect,
+ render_template,
+ request,
+ url_for
+)
+from flask_login import current_user, login_required
+from app import db
+from app.models import Avatar
from . import bp
+from .forms import (
+ UpdateAvatarForm,
+ UpdatePasswordForm,
+ UpdateNotificationsForm,
+ UpdateAccountInformationForm,
+ UpdateProfileInformationForm
+)
-@bp.route('/settings', methods=['GET', 'POST'])
-def settings():
- g._nopaque_redirect_location_on_post = url_for('.settings')
- return settings_route(current_user.id)
+@bp.route('', methods=['GET', 'POST'])
+@login_required
+def index():
+ update_account_information_form = UpdateAccountInformationForm(current_user)
+ update_profile_information_form = UpdateProfileInformationForm(current_user)
+ update_avatar_form = UpdateAvatarForm()
+ update_password_form = UpdatePasswordForm(current_user)
+ update_notifications_form = UpdateNotificationsForm(current_user)
+
+ # region handle update profile information form
+ if update_profile_information_form.submit.data and update_profile_information_form.validate():
+ current_user.about_me = update_profile_information_form.about_me.data
+ current_user.location = update_profile_information_form.location.data
+ current_user.organization = update_profile_information_form.organization.data
+ current_user.website = update_profile_information_form.website.data
+ current_user.full_name = update_profile_information_form.full_name.data
+ db.session.commit()
+ flash('Your changes have been saved')
+ return redirect(url_for('.index'))
+ # 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=current_user
+ )
+ except (AttributeError, OSError):
+ abort(500)
+ db.session.commit()
+ flash('Your changes have been saved')
+ return redirect(url_for('.index'))
+ # endregion handle update avatar form
+
+ # region handle update account information form
+ if update_account_information_form.submit.data and update_account_information_form.validate():
+ current_user.email = update_account_information_form.email.data
+ current_user.username = update_account_information_form.username.data
+ db.session.commit()
+ flash('Profile settings updated')
+ return redirect(url_for('.index'))
+ # endregion handle update account information form
+
+ # region handle update password form
+ if update_password_form.submit.data and update_password_form.validate():
+ current_user.password = update_password_form.new_password.data
+ db.session.commit()
+ flash('Your changes have been saved')
+ return redirect(url_for('.index'))
+ # endregion handle update password form
+
+ # region handle update notifications form
+ if update_notifications_form.submit.data and update_notifications_form.validate():
+ current_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('.index'))
+ # endregion handle update notifications form
+
+ return render_template(
+ 'settings/index.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=current_user
+ )
+
+
+@bp.route('/profile-is-public', methods=['PUT'])
+@login_required
+def update_profile_is_public():
+ new_value = request.json
+
+ if not isinstance(new_value, bool):
+ abort(400)
+
+ current_user.is_public = new_value
+ db.session.commit()
+
+ return jsonify('Your changes have been saved'), 200
+
+
+@bp.route('/profile-show-email', methods=['PUT'])
+@login_required
+def update_profile_show_email():
+ new_value = request.json
+
+ if not isinstance(new_value, bool):
+ abort(400)
+
+ if new_value:
+ current_user.add_profile_privacy_setting('SHOW_EMAIL')
+ else:
+ current_user.remove_profile_privacy_setting('SHOW_EMAIL')
+ db.session.commit()
+
+ return jsonify('Your changes have been saved'), 200
+
+
+@bp.route('/profile-show-last-seen', methods=['PUT'])
+@login_required
+def update_profile_show_last_seen():
+ new_value = request.json
+
+ if not isinstance(new_value, bool):
+ abort(400)
+
+ if new_value:
+ current_user.add_profile_privacy_setting('SHOW_LAST_SEEN')
+ else:
+ current_user.remove_profile_privacy_setting('SHOW_LAST_SEEN')
+ db.session.commit()
+
+ return jsonify('Your changes have been saved'), 200
+
+
+@bp.route('/profile-show-member-since', methods=['PUT'])
+@login_required
+def update_profile_show_member_since():
+ new_value = request.json
+
+ if not isinstance(new_value, bool):
+ abort(400)
+
+ if new_value:
+ current_user.add_profile_privacy_setting('SHOW_MEMBER_SINCE')
+ else:
+ current_user.remove_profile_privacy_setting('SHOW_MEMBER_SINCE')
+ db.session.commit()
+
+ return jsonify('Your changes have been saved'), 200
diff --git a/app/blueprints/users/__init__.py b/app/blueprints/users/__init__.py
index 21e9c382..8b9171bf 100644
--- a/app/blueprints/users/__init__.py
+++ b/app/blueprints/users/__init__.py
@@ -1,18 +1,7 @@
from flask import Blueprint
-from flask_login import login_required
bp = Blueprint('users', __name__)
-@bp.before_request
-@login_required
-def before_request():
- '''
- Ensures that the routes in this package can only be visited by users that
- are logged in.
- '''
- pass
-
-
-from . import cli, json_routes, routes, settings
+from . import cli, events, routes
diff --git a/app/blueprints/users/events.py b/app/blueprints/users/events.py
new file mode 100644
index 00000000..fcbe18bd
--- /dev/null
+++ b/app/blueprints/users/events.py
@@ -0,0 +1,58 @@
+from flask_login import current_user
+from flask_socketio import join_room, leave_room
+from app import hashids, socketio
+from app.decorators import socketio_login_required
+from app.models import User
+
+
+@socketio.on('SUBSCRIBE User')
+@socketio_login_required
+def subscribe(user_hashid: str) -> dict:
+ if not isinstance(user_hashid, str):
+ return {'status': 400, 'statusText': 'Bad Request'}
+
+ user_id = hashids.decode(user_hashid)
+
+ if not isinstance(user_id, int):
+ return {'status': 400, 'statusText': 'Bad Request'}
+
+ user = User.query.get(user_id)
+
+ if user is None:
+ return {'status': 404, 'statusText': 'Not Found'}
+
+ if not (
+ user == current_user
+ or current_user.is_administrator
+ ):
+ return {'status': 403, 'statusText': 'Forbidden'}
+
+ join_room(f'/users/{user.hashid}')
+
+ return {'status': 200, 'statusText': 'OK'}
+
+@socketio.on('UNSUBSCRIBE User')
+@socketio_login_required
+def unsubscribe(user_hashid: str) -> dict:
+ if not isinstance(user_hashid, str):
+ return {'status': 400, 'statusText': 'Bad Request'}
+
+ user_id = hashids.decode(user_hashid)
+
+ if not isinstance(user_id, int):
+ return {'status': 400, 'statusText': 'Bad Request'}
+
+ user = User.query.get(user_id)
+
+ if user is None:
+ return {'status': 404, 'statusText': 'Not Found'}
+
+ if not (
+ user == current_user
+ or current_user.is_administrator
+ ):
+ return {'status': 403, 'statusText': 'Forbidden'}
+
+ leave_room(f'/users/{user.hashid}')
+
+ return {'status': 200, 'statusText': 'OK'}
diff --git a/app/blueprints/users/json_routes.py b/app/blueprints/users/json_routes.py
deleted file mode 100644
index d4358db2..00000000
--- a/app/blueprints/users/json_routes.py
+++ /dev/null
@@ -1,69 +0,0 @@
-from flask import abort, current_app
-from flask_login import current_user, logout_user
-from threading import Thread
-from app import db
-from app.decorators import content_negotiation
-from app.models import Avatar, User
-from . import bp
-
-
-@bp.route('/', methods=['DELETE'])
-@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_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)
- 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)
- )
- thread.start()
- response_data = {
- 'message': f'Avatar marked for deletion'
- }
- return response_data, 202
-
-@bp.route('/accept-terms-of-use', methods=['POST'])
-@content_negotiation(produces='application/json')
-def accept_terms_of_use():
- if not (current_user.is_authenticated or current_user.confirmed):
- abort(403)
- current_user.terms_of_use_accepted = True
- db.session.commit()
- response_data = {
- 'message': 'You accepted the terms of use',
- }
- return response_data, 202
diff --git a/app/blueprints/users/routes.py b/app/blueprints/users/routes.py
index b4b81ffb..3f2dec1b 100644
--- a/app/blueprints/users/routes.py
+++ b/app/blueprints/users/routes.py
@@ -1,25 +1,48 @@
from flask import (
abort,
- redirect,
- render_template,
+ current_app,
+ Flask,
+ jsonify,
+ redirect,
+ render_template,
+ request,
send_from_directory,
url_for
)
-from flask_login import current_user
-from app.models import User
+from flask_login import current_user, login_required, logout_user
+from threading import Thread
+from app import db
+from app.models import Avatar, User
from . import bp
@bp.route('')
-def users():
+@login_required
+def index():
return redirect(url_for('main.social_area', _anchor='users'))
@bp.route('/')
-def user(user_id):
+@login_required
+def user(user_id: int):
user = User.query.get_or_404(user_id)
- if not (user.is_public or user == current_user or current_user.is_administrator):
+
+ if not (
+ user.is_public
+ or user == current_user
+ or current_user.is_administrator
+ ):
abort(403)
+
+ accept_json = request.accept_mimetypes.accept_json
+ accept_html = request.accept_mimetypes.accept_html
+
+ if accept_json and not accept_html:
+ return user.to_json_serializeable(
+ backrefs=True,
+ relationships=True
+ )
+
return render_template(
'users/user.html.j2',
title=user.username,
@@ -27,13 +50,51 @@ def user(user_id):
)
-@bp.route('//avatar')
-def user_avatar(user_id):
+def _delete_user(app: Flask, user_id: int):
+ with app.app_context():
+ user = User.query.get(user_id)
+ user.delete()
+ db.session.commit()
+
+
+@bp.route('/', methods=['DELETE'])
+@login_required
+def delete_user(user_id: int):
user = User.query.get_or_404(user_id)
- if not (user.is_public or user == current_user or current_user.is_administrator):
+
+ if not (
+ user == current_user
+ or current_user.is_administrator
+ ):
abort(403)
+
+ if user == current_user:
+ logout_user()
+
+ thread = Thread(
+ target=_delete_user,
+ args=(current_app._get_current_object(), user.id)
+ )
+ thread.start()
+
+ return jsonify(f'User "{user.username}" marked for deletion'), 202
+
+
+@bp.route('//avatar')
+@login_required
+def user_avatar(user_id: int):
+ user = User.query.get_or_404(user_id)
+
+ if not (
+ user.is_public
+ or user == current_user
+ or current_user.is_administrator
+ ):
+ abort(403)
+
if user.avatar is None:
return redirect(url_for('static', filename='images/user_avatar.png'))
+
return send_from_directory(
user.avatar.path.parent,
user.avatar.path.name,
@@ -41,3 +102,49 @@ def user_avatar(user_id):
download_name=user.avatar.filename,
mimetype=user.avatar.mimetype
)
+
+
+def _delete_avatar(app: Flask, avatar_id: int):
+ with app.app_context():
+ avatar = Avatar.query.get(avatar_id)
+ avatar.delete()
+ db.session.commit()
+
+
+@bp.route('//avatar', methods=['DELETE'])
+@login_required
+def delete_user_avatar(user_id: int):
+ user = User.query.get_or_404(user_id)
+
+ if user.avatar is None:
+ abort(409)
+
+ 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)
+ )
+ thread.start()
+
+ return jsonify('Avatar marked for deletion'), 202
+
+
+# TODO: Move this to main blueprint(?)
+@bp.route('/accept-terms-of-use', methods=['POST'])
+@login_required
+def accept_terms_of_use():
+ if not (
+ current_user.is_authenticated
+ or current_user.confirmed
+ ):
+ abort(403)
+
+ current_user.terms_of_use_accepted = True
+ db.session.commit()
+
+ return jsonify('You accepted the terms of use'), 202
diff --git a/app/blueprints/users/settings/__init__.py b/app/blueprints/users/settings/__init__.py
deleted file mode 100644
index e06bada9..00000000
--- a/app/blueprints/users/settings/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-from .. import bp
-from . import json_routes, routes
diff --git a/app/blueprints/users/settings/json_routes.py b/app/blueprints/users/settings/json_routes.py
deleted file mode 100644
index e75fb7d4..00000000
--- a/app/blueprints/users/settings/json_routes.py
+++ /dev/null
@@ -1,49 +0,0 @@
-from flask import abort, request
-from flask_login import current_user
-from app import db
-from app.decorators import content_negotiation
-from app.models import User, ProfilePrivacySettings
-from . import bp
-
-
-@bp.route('//settings/profile-privacy/is-public', methods=['PUT'])
-@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)
- 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 = enabled
- db.session.commit()
- response_data = {
- 'message': 'Profile privacy settings updated',
- 'category': 'settings'
- }
- return response_data, 200
-
-
-@bp.route('//settings/profile-privacy/', methods=['PUT'])
-@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)
- 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:
- user.remove_profile_privacy_setting(profile_privacy_setting)
- db.session.commit()
- response_data = {
- 'message': 'Profile privacy settings updated',
- 'category': 'settings'
- }
- return response_data, 200
diff --git a/app/blueprints/users/settings/routes.py b/app/blueprints/users/settings/routes.py
deleted file mode 100644
index 0916ef70..00000000
--- a/app/blueprints/users/settings/routes.py
+++ /dev/null
@@ -1,93 +0,0 @@
-from flask import abort, flash, g, redirect, render_template, url_for
-from flask_login import current_user
-from app import db
-from app.models import Avatar, User
-from . import bp
-from .forms import (
- UpdateAvatarForm,
- UpdatePasswordForm,
- UpdateNotificationsForm,
- UpdateAccountInformationForm,
- UpdateProfileInformationForm
-)
-
-
-@bp.route('//settings', methods=['GET', 'POST'])
-def settings(user_id):
- user = User.query.get_or_404(user_id)
- if not (user == current_user or current_user.is_administrator):
- abort(403)
-
- redirect_location_on_post = g.pop(
- '_nopaque_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
- )
diff --git a/app/models/event_listeners.py b/app/models/event_listeners.py
index b9f9a652..60cc774e 100644
--- a/app/models/event_listeners.py
+++ b/app/models/event_listeners.py
@@ -42,9 +42,8 @@ def resource_after_delete(mapper, connection, resource):
'path': resource.jsonpatch_path
}
]
- namespace = '/users'
room = f'/users/{resource.user_hashid}'
- socketio.emit('patch', jsonpatch, namespace=namespace, room=room)
+ socketio.emit('PATCH', jsonpatch, room=room)
def cfa_after_delete(mapper, connection, cfa):
@@ -55,9 +54,8 @@ def cfa_after_delete(mapper, connection, cfa):
'path': jsonpatch_path
}
]
- namespace = '/users'
room = f'/users/{cfa.corpus.user.hashid}'
- socketio.emit('patch', jsonpatch, namespace=namespace, room=room)
+ socketio.emit('PATCH', jsonpatch, room=room)
def resource_after_insert(mapper, connection, resource):
@@ -71,9 +69,8 @@ def resource_after_insert(mapper, connection, resource):
'value': jsonpatch_value
}
]
- namespace = '/users'
room = f'/users/{resource.user_hashid}'
- socketio.emit('patch', jsonpatch, namespace=namespace, room=room)
+ socketio.emit('PATCH', jsonpatch, room=room)
def cfa_after_insert(mapper, connection, cfa):
@@ -86,9 +83,8 @@ def cfa_after_insert(mapper, connection, cfa):
'value': jsonpatch_value
}
]
- namespace = '/users'
room = f'/users/{cfa.corpus.user.hashid}'
- socketio.emit('patch', jsonpatch, namespace=namespace, room=room)
+ socketio.emit('PATCH', jsonpatch, room=room)
def resource_after_update(mapper, connection, resource):
@@ -113,9 +109,8 @@ def resource_after_update(mapper, connection, resource):
}
)
if jsonpatch:
- namespace = '/users'
room = f'/users/{resource.user_hashid}'
- socketio.emit('patch', jsonpatch, namespace=namespace, room=room)
+ socketio.emit('PATCH', jsonpatch, room=room)
def job_after_update(mapper, connection, job):
diff --git a/app/namespaces/users.py b/app/namespaces/users.py
deleted file mode 100644
index 5a0bc014..00000000
--- a/app/namespaces/users.py
+++ /dev/null
@@ -1,128 +0,0 @@
-from flask import current_app, Flask
-from flask_login import current_user
-from flask_socketio import join_room, leave_room, Namespace
-from app import db, hashids, socketio
-from app.decorators import socketio_login_required
-from app.models import User
-
-
-def _delete_user(app: Flask, user_id: int):
- with app.app_context():
- user = User.query.get(user_id)
- user.delete()
- db.session.commit()
-
-
-class UsersNamespace(Namespace):
- @socketio_login_required
- def on_get(self, user_hashid: str) -> dict:
- if not isinstance(user_hashid, str):
- return {'status': 400, 'statusText': 'Bad Request'}
-
- user_id = hashids.decode(user_hashid)
-
- if not isinstance(user_id, int):
- return {'status': 400, 'statusText': 'Bad Request'}
-
- user = User.query.get(user_id)
-
- if user is None:
- return {'status': 404, 'statusText': 'Not Found'}
-
- if not (
- user == current_user
- or current_user.is_administrator
- ):
- return {'status': 403, 'statusText': 'Forbidden'}
-
- return {
- 'body': user.to_json_serializeable(
- backrefs=True,
- relationships=True
- ),
- 'status': 200,
- 'statusText': 'OK'
- }
-
- @socketio_login_required
- def on_subscribe(self, user_hashid: str) -> dict:
- if not isinstance(user_hashid, str):
- return {'status': 400, 'statusText': 'Bad Request'}
-
- user_id = hashids.decode(user_hashid)
-
- if not isinstance(user_id, int):
- return {'status': 400, 'statusText': 'Bad Request'}
-
- user = User.query.get(user_id)
-
- if user is None:
- return {'status': 404, 'statusText': 'Not Found'}
-
- if not (
- user == current_user
- or current_user.is_administrator
- ):
- return {'status': 403, 'statusText': 'Forbidden'}
-
- join_room(f'/users/{user.hashid}')
-
- return {'status': 200, 'statusText': 'OK'}
-
- @socketio_login_required
- def on_unsubscribe(self, user_hashid: str) -> dict:
- if not isinstance(user_hashid, str):
- return {'status': 400, 'statusText': 'Bad Request'}
-
- user_id = hashids.decode(user_hashid)
-
- if not isinstance(user_id, int):
- return {'status': 400, 'statusText': 'Bad Request'}
-
- user = User.query.get(user_id)
-
- if user is None:
- return {'status': 404, 'statusText': 'Not Found'}
-
- if not (
- user == current_user
- or current_user.is_administrator
- ):
- return {'status': 403, 'statusText': 'Forbidden'}
-
- leave_room(f'/users/{user.hashid}')
-
- return {'status': 200, 'statusText': 'OK'}
-
- @socketio_login_required
- def on_delete(self, user_hashid: str) -> dict:
- if not isinstance(user_hashid, str):
- return {'status': 400, 'statusText': 'Bad Request'}
-
- user_id = hashids.decode(user_hashid)
-
- if not isinstance(user_id, int):
- return {'status': 400, 'statusText': 'Bad Request'}
-
- user = User.query.get(user_id)
-
- if user is None:
- return {'status': 404, 'statusText': 'Not Found'}
-
- if not (
- user == current_user
- or current_user.is_administrator
- ):
- return {'status': 403, 'statusText': 'Forbidden'}
-
- socketio.start_background_task(
- _delete_user,
- current_app._get_current_object(),
- user.id
- )
-
- return {
- 'body': f'User "{user.username}" marked for deletion',
- 'status': 202,
- 'statusText': 'Accepted'
- }
diff --git a/app/static/js/app/client.js b/app/static/js/app/client.js
index 2891f8e1..faa6c264 100644
--- a/app/static/js/app/client.js
+++ b/app/static/js/app/client.js
@@ -5,6 +5,7 @@ nopaque.app.Client = class Client {
// Endpoints
this.corpora = new nopaque.app.endpoints.Corpora(this);
this.jobs = new nopaque.app.endpoints.Jobs(this);
+ this.settings = new nopaque.app.endpoints.Settings(this);
this.users = new nopaque.app.endpoints.Users(this);
// Extensions
diff --git a/app/static/js/app/endpoints/settings.js b/app/static/js/app/endpoints/settings.js
new file mode 100644
index 00000000..7c4646f9
--- /dev/null
+++ b/app/static/js/app/endpoints/settings.js
@@ -0,0 +1,77 @@
+nopaque.app.endpoints.Settings = class Settings {
+ constructor(app) {
+ this.app = app;
+ }
+
+ async updateProfileIsPublic(newValue) {
+ const options = {
+ body: JSON.stringify(newValue),
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json'
+ },
+ method: 'PUT',
+ };
+
+ const response = await fetch(`/settings/profile-is-public`, options);
+ const data = await response.json();
+
+ if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
+
+ return data;
+ }
+
+ async updateProfileShowEmail(newValue) {
+ const options = {
+ body: JSON.stringify(newValue),
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json'
+ },
+ method: 'PUT',
+ };
+
+ const response = await fetch(`/settings/profile-show-email`, options);
+ const data = await response.json();
+
+ if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
+
+ return data;
+ }
+
+ async updateProfileShowLastSeen(newValue) {
+ const options = {
+ body: JSON.stringify(newValue),
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json'
+ },
+ method: 'PUT',
+ };
+
+ const response = await fetch(`/settings/profile-show-last-seen`, options);
+ const data = await response.json();
+
+ if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
+
+ return data;
+ }
+
+ async updateProfileShowMemberSince(newValue) {
+ const options = {
+ body: JSON.stringify(newValue),
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json'
+ },
+ method: 'PUT',
+ };
+
+ const response = await fetch(`/settings/profile-show-member-since`, options);
+ const data = await response.json();
+
+ if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
+
+ return data;
+ }
+}
diff --git a/app/static/js/app/endpoints/users.js b/app/static/js/app/endpoints/users.js
index b7dec629..e7d4496f 100644
--- a/app/static/js/app/endpoints/users.js
+++ b/app/static/js/app/endpoints/users.js
@@ -1,43 +1,52 @@
nopaque.app.endpoints.Users = class Users {
constructor(app) {
this.app = app;
-
- this.socket = io('/users', {transports: ['websocket'], upgrade: false});
}
- async get(id) {
- const response = await this.socket.emitWithAck('get', id);
+ async get(userId) {
+ const options = {
+ headers: {
+ Accept: 'application/json'
+ }
+ };
- if (response.status !== 200) {
- throw new Error(`[${response.status}] ${response.statusText}`);
- }
+ const response = await fetch(`/users/${userId}`, options);
+ const data = await response.json();
- return response.body;
+ if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
+
+ return data;
}
- async subscribe(id) {
- const response = await this.socket.emitWithAck('subscribe', id);
+ async subscribe(userId) {
+ const response = await this.app.socket.emitWithAck('SUBSCRIBE User', userId);
if (response.status != 200) {
throw new Error(`[${response.status}] ${response.statusText}`);
}
}
- async unsubscribe(id) {
- const response = await this.socket.emitWithAck('unsubscribe', id);
+ async unsubscribe(userId) {
+ const response = await this.app.socket.emitWithAck('UNSUBSCRIBE User', userId);
if (response.status != 200) {
throw new Error(`[${response.status}] ${response.statusText}`);
}
}
- async delete(id) {
- const response = await this.socket.emitWithAck('delete', id);
+ async delete(userId) {
+ const options = {
+ headers: {
+ Accept: 'application/json'
+ },
+ method: 'DELETE'
+ };
- if (response.status != 202) {
- throw new Error(`[${response.status}] ${response.statusText}`);
- }
+ const response = await fetch(`/users/${userId}`, options);
+ const data = await response.json();
- return response.body;
+ if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
+
+ return data;
}
}
diff --git a/app/static/js/app/extensions/user-hub.js b/app/static/js/app/extensions/user-hub.js
index 41f642ea..4a9748b8 100644
--- a/app/static/js/app/extensions/user-hub.js
+++ b/app/static/js/app/extensions/user-hub.js
@@ -13,7 +13,7 @@ nopaque.app.extensions.UserHub = class UserHub extends EventTarget {
}
init() {
- this.app.users.socket.on('patch', (patch) => {this.#onPatch(patch)});
+ this.app.socket.on('PATCH', (patch) => {this.#onPatch(patch)});
}
add(userId) {
diff --git a/app/templates/_base/dropdowns.html.j2 b/app/templates/_base/dropdowns.html.j2
index 616b0103..3c1b2772 100644
--- a/app/templates/_base/dropdowns.html.j2
+++ b/app/templates/_base/dropdowns.html.j2
@@ -44,8 +44,8 @@
Your profile
-
-
+
+
settings
Settings
diff --git a/app/templates/_base/scripts.html.j2 b/app/templates/_base/scripts.html.j2
index 3421bf0e..24bdc2ab 100644
--- a/app/templates/_base/scripts.html.j2
+++ b/app/templates/_base/scripts.html.j2
@@ -13,6 +13,7 @@
'js/app/endpoints/index.js',
'js/app/endpoints/corpora.js',
'js/app/endpoints/jobs.js',
+ 'js/app/endpoints/settings.js',
'js/app/endpoints/users.js',
'js/app/extensions/index.js',
'js/app/extensions/toaster.js',
diff --git a/app/templates/_base/sidenav.html.j2 b/app/templates/_base/sidenav.html.j2
index 56b08dfe..dff0b45c 100644
--- a/app/templates/_base/sidenav.html.j2
+++ b/app/templates/_base/sidenav.html.j2
@@ -85,8 +85,8 @@
{# settings #}
-
- settingsSettings
+
+ settingsSettings
{# log out #}
diff --git a/app/templates/users/settings/settings.html.j2 b/app/templates/settings/index.html.j2
similarity index 89%
rename from app/templates/users/settings/settings.html.j2
rename to app/templates/settings/index.html.j2
index 0f0b440e..a27e7829 100644
--- a/app/templates/users/settings/settings.html.j2
+++ b/app/templates/settings/index.html.j2
@@ -46,19 +46,19 @@