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 @@
    @@ -74,7 +74,7 @@
    {{ update_profile_information_form.hidden_tag() }} {{ wtf.render_field(update_profile_information_form.full_name, material_icon='badge') }} - {{ wtf.render_field(update_profile_information_form.about_me, material_icon='description', id='about-me-textfield') }} + {{ wtf.render_field(update_profile_information_form.about_me, material_icon='description') }} {{ wtf.render_field(update_profile_information_form.website, material_icon='laptop') }} {{ wtf.render_field(update_profile_information_form.organization, material_icon='business') }} {{ wtf.render_field(update_profile_information_form.location, material_icon='location_on') }} @@ -252,28 +252,28 @@ for (let collapsibleElement of document.querySelectorAll('.collapsible.no-autoin // #region Profile Privacy settings let profileIsPublicSwitchElement = document.querySelector('#profile-is-public-switch'); let profilePrivacySettingCheckboxElements = document.querySelectorAll('.profile-privacy-setting-checkbox'); -profileIsPublicSwitchElement.addEventListener('change', (event) => { - let newEnabled = profileIsPublicSwitchElement.checked; - nopaque.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 = !newEnabled; - } - }, - (response) => { - profileIsPublicSwitchElement.checked = !newEnabled; - } - ); +profileIsPublicSwitchElement.addEventListener('change', async (event) => { + const newEnabled = profileIsPublicSwitchElement.checked; + try { + const message = await app.settings.updateProfileIsPublic(newEnabled); + for (let profilePrivacySettingCheckboxElement of profilePrivacySettingCheckboxElements) { + profilePrivacySettingCheckboxElement.disabled = !newEnabled; + } + app.ui.flash(message); + } catch (e) { + profileIsPublicSwitchElement.checked = !newEnabled; + app.ui.flash(e.message, 'error'); + } }); for (let profilePrivacySettingCheckboxElement of profilePrivacySettingCheckboxElements) { - profilePrivacySettingCheckboxElement.addEventListener('change', (event) => { - let newEnabled = profilePrivacySettingCheckboxElement.checked; - let valueName = profilePrivacySettingCheckboxElement.dataset.profilePrivacySettingName; - nopaque.requests.users.entity.settings.profilePrivacy.update({{ user.hashid|tojson }}, valueName, newEnabled) - .catch((response) => { + profilePrivacySettingCheckboxElement.addEventListener('change', async (event) => { + const newEnabled = profilePrivacySettingCheckboxElement.checked; + const valueName = profilePrivacySettingCheckboxElement.dataset.profilePrivacySettingName; + try { + app.settings[`update${valueName}`](newEnabled) + } catch (error) { profilePrivacySettingCheckboxElement.checked = !newEnabled; - }); + } }); } // #endregion Profile Privacy settings