Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus

This commit is contained in:
Patrick Jentsch 2023-03-13 16:23:51 +01:00
commit bac526b927
23 changed files with 334 additions and 532 deletions

View File

@ -88,9 +88,6 @@ def create_app(config: Config = Config) -> Flask:
default_breadcrumb_root(services_blueprint, '.services') default_breadcrumb_root(services_blueprint, '.services')
app.register_blueprint(services_blueprint, url_prefix='/services') 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 from .users import bp as users_blueprint
app.register_blueprint(users_blueprint, url_prefix='/users') app.register_blueprint(users_blueprint, url_prefix='/users')

View File

@ -4,7 +4,7 @@ from threading import Thread
from app import db, hashids from app import db, hashids
from app.decorators import admin_required from app.decorators import admin_required
from app.models import Role, User, UserSettingJobStatusMailNotificationLevel from app.models import Role, User, UserSettingJobStatusMailNotificationLevel
from app.settings.forms import ( from app.users.forms import (
EditNotificationSettingsForm EditNotificationSettingsForm
) )
from app.users.forms import EditProfileSettingsForm from app.users.forms import EditProfileSettingsForm

View File

@ -2,7 +2,12 @@ from flask import abort, flash, redirect, render_template, url_for
from flask_login import current_user, login_required from flask_login import current_user, login_required
from .decorators import corpus_follower_permission_required from .decorators import corpus_follower_permission_required
from app import db from app import db
from app.models import Corpus, CorpusFollowerAssociation, CorpusFollowerRole from app.models import (
Corpus,
CorpusFollowerAssociation,
CorpusFollowerRole,
User
)
from . import bp from . import bp
from .forms import CreateCorpusForm from .forms import CreateCorpusForm
@ -41,12 +46,14 @@ def create_corpus():
def corpus(corpus_id): def corpus(corpus_id):
corpus = Corpus.query.get_or_404(corpus_id) corpus = Corpus.query.get_or_404(corpus_id)
corpus_follower_roles = CorpusFollowerRole.query.all() 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 # TODO: Add URL query option to toggle view
if corpus.user == current_user or current_user.is_administrator(): if corpus.user == current_user or current_user.is_administrator():
return render_template( return render_template(
'corpora/corpus.html.j2', 'corpora/corpus.html.j2',
corpus=corpus, corpus=corpus,
corpus_follower_roles=corpus_follower_roles, corpus_follower_roles=corpus_follower_roles,
users = users,
title='Corpus' title='Corpus'
) )
if current_user.is_following_corpus(corpus) or corpus.is_public: if current_user.is_following_corpus(corpus) or corpus.is_public:

View File

@ -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 flask_login import current_user, login_required
from threading import Thread from threading import Thread
import os import os
@ -7,6 +7,7 @@ from app.decorators import admin_required, content_negotiation
from app.models import Job, JobStatus from app.models import Job, JobStatus
from . import bp from . import bp
@bp.route('/<hashid:job_id>', methods=['DELETE']) @bp.route('/<hashid:job_id>', methods=['DELETE'])
@login_required @login_required
@content_negotiation(produces='application/json') @content_negotiation(produces='application/json')
@ -26,12 +27,10 @@ def delete_job(job_id):
) )
thread.start() thread.start()
response_data = { response_data = {
'message': \ 'message': f'Job "{job.title}" marked for deletion'
f'Job "{job.title}" marked for deletion'
} }
response = jsonify(response_data) return response_data, 202
response.status_code = 202
return response
@bp.route('/<hashid:job_id>/log') @bp.route('/<hashid:job_id>/log')
@login_required @login_required
@ -48,9 +47,7 @@ def job_log(job_id):
'message': '', 'message': '',
'jobLog': log 'jobLog': log
} }
response = jsonify(response_data) return response_data, 200
response.status_code = 200
return response
@bp.route('/<hashid:job_id>/restart', methods=['POST']) @bp.route('/<hashid:job_id>/restart', methods=['POST'])
@ -75,9 +72,6 @@ def restart_job(job_id):
) )
thread.start() thread.start()
response_data = { response_data = {
'message': \ 'message': f'Job "{job.title}" marked for restarting'
f'Job "{job.title}" marked for restarting'
} }
response = jsonify(response_data) return response_data, 202
response.status_code = 202
return response

View File

@ -1,5 +0,0 @@
from flask import Blueprint
bp = Blueprint('settings', __name__)
from . import routes # noqa

View File

@ -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()

View File

@ -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'
)

View File

@ -1,3 +1,8 @@
.parallax-container .parallax { .parallax-container .parallax {
z-index: 0; z-index: 0;
} }
.autocomplete-content {
width: 100% !important;
left: 0 !important;
}

View File

@ -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);
}

View File

@ -69,240 +69,4 @@ class Utils {
return Utils.mergeObjectsDeep(mergedObject, ...objects.slice(2)); return Utils.mergeObjectsDeep(mergedObject, ...objects.slice(2));
} }
static deleteProfileAvatarRequest(userId) {
return new Promise((resolve, reject) => {
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm Avatar deletion</h4>
<p>Do you really want to delete your Avatar?</p>
</div>
<div class="modal-footer">
<a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
<a class="action-button btn modal-close red waves-effect waves-light" data-action="confirm">Delete</a>
</div>
</div>
`
);
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(
// `
// <div class="modal">
// <div class="modal-content">
// <h4>Job logs</h4>
// <pre><code>${text}</code></pre>
// </div>
// <div class="modal-footer">
// <a class="btn modal-close waves-effect waves-light">Close</a>
// </div>
// </div>
// `
// );
// 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(
// `
// <div class="modal">
// <div class="modal-content">
// <h4>Confirm Job restart</h4>
// <p>Do you really want to restart the Job <b>${job?.title}</b>? All Job Results will be permanently deleted.</p>
// </div>
// <div class="modal-footer">
// <a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
// <a class="action-button btn modal-close red waves-effect waves-light" data-action="confirm">Restart</a>
// </div>
// </div>
// `
// );
// 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(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm User deletion</h4>
<p>Do you really want to delete the User <b>${user?.username}</b>? All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
<a class="action-button btn modal-close red waves-effect waves-light" data-action="confirm">Delete</a>
</div>
</div>
`
);
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();
});
}
} }

View File

@ -31,8 +31,7 @@
<ul class="dropdown-content" id="nav-more-dropdown"> <ul class="dropdown-content" id="nav-more-dropdown">
<li><a href="{{ url_for('main.user_manual') }}"><i class="material-icons left">help</i>Manual</a></li> <li><a href="{{ url_for('main.user_manual') }}"><i class="material-icons left">help</i>Manual</a></li>
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<li><a href="{{ url_for('settings.settings') }}"><i class="material-icons left">settings</i>General settings</a></li> <li><a href="{{ url_for('users.edit_profile', user_id=current_user.id) }}"><i class="material-icons left">settings</i>User settings</a></li>
<li><a href="{{ url_for('users.edit_profile', user_id=current_user.id) }}"><i class="material-icons left">contact_page</i>Profile settings</a></li>
<li class="divider" tabindex="-1"></li> <li class="divider" tabindex="-1"></li>
<li><a href="{{ url_for('auth.logout') }}">Log out</a></li> <li><a href="{{ url_for('auth.logout') }}">Log out</a></li>
{% else %} {% else %}

View File

@ -64,7 +64,8 @@
'js/Requests/corpora/corpora.js', 'js/Requests/corpora/corpora.js',
'js/Requests/corpora/files.js', 'js/Requests/corpora/files.js',
'js/Requests/corpora/followers.js', 'js/Requests/corpora/followers.js',
'js/Requests/jobs/jobs.js' 'js/Requests/jobs/jobs.js',
'js/Requests/users/users.js'
%} %}
<script src="{{ ASSET_URL }}"></script> <script src="{{ ASSET_URL }}"></script>
{%- endassets %} {%- endassets %}

View File

@ -38,8 +38,7 @@
<li class="service-color service-color-border border-darken" data-service="corpus-analysis" style="border-left: 10px solid; margin-top: 5px;"><a href="{{ url_for('services.corpus_analysis') }}"><i class="nopaque-icons service-icons" data-service="corpus-analysis"></i>Corpus Analysis</a></li> <li class="service-color service-color-border border-darken" data-service="corpus-analysis" style="border-left: 10px solid; margin-top: 5px;"><a href="{{ url_for('services.corpus_analysis') }}"><i class="nopaque-icons service-icons" data-service="corpus-analysis"></i>Corpus Analysis</a></li>
<li><div class="divider"></div></li> <li><div class="divider"></div></li>
<li><a class="subheader">Account</a></li> <li><a class="subheader">Account</a></li>
<li><a href="{{ url_for('settings.settings') }}"><i class="material-icons">settings</i>General Settings</a></li> <li><a href="{{ url_for('users.edit_profile', user_id=current_user.id) }}"><i class="material-icons left">settings</i>User Settings</a></li>
<li><a href="{{ url_for('users.edit_profile', user_id=current_user.id) }}"><i class="material-icons left">contact_page</i>Profile settings</a></li>
<li><a href="{{ url_for('auth.logout') }}">Log out</a></li> <li><a href="{{ url_for('auth.logout') }}">Log out</a></li>
{% if current_user.can('ADMINISTRATE') or current_user.can('USE_API') %} {% if current_user.can('ADMINISTRATE') or current_user.can('USE_API') %}
<li><div class="divider"></div></li> <li><div class="divider"></div></li>

View File

@ -26,16 +26,17 @@ deleteModalDeleteButtonElement.addEventListener('click', (event) => {
let inviteUserModalElement = document.querySelector('#invite-user-modal'); let inviteUserModalElement = document.querySelector('#invite-user-modal');
let inviteUserModalSearchElement = document.querySelector('#invite-user-modal-search'); let inviteUserModalSearchElement = document.querySelector('#invite-user-modal-search');
let inviteUserModalInviteButtonElement = document.querySelector('#invite-user-modal-invite-button'); 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( let inviteUserModalSearch = M.Chips.init(
inviteUserModalSearchElement, inviteUserModalSearchElement,
{ {
autocompleteOptions: { autocompleteOptions: {
data: { data: users
'nopaque': '/users/3V8Aqpg74JvxOd9o/avatar',
'pjentsch': '/users/3V8Aqpg74JvxOd9o/avatar',
'pjentsch2': '/users/3V8Aqpg74JvxOd9o/avatar'
}
}, },
limit: 3, limit: 3,
onChipAdd: (a, chipElement) => { onChipAdd: (a, chipElement) => {
@ -43,7 +44,7 @@ let inviteUserModalSearch = M.Chips.init(
chipElement.firstElementChild.click(); chipElement.firstElementChild.click();
} }
}, },
placeholder: 'Enter a username', placeholder: 'Enter username',
secondaryPlaceholder: 'Add more users' secondaryPlaceholder: 'Add more users'
} }
); );

View File

@ -1,69 +0,0 @@
{% extends "base.html.j2" %}
{% import "materialize/wtf.html.j2" as wtf %}
{% block page_content %}
<div class="container">
<div class="row">
<div class="col s12">
<h1 id="title">{{ title }}</h1>
</div>
<div class="col s12">
<form method="POST">
{{ edit_notification_settings_form.hidden_tag() }}
<div class="card">
<div class="card-content">
<span class="card-title">Notification settings</span>
{{ wtf.render_field(edit_notification_settings_form.job_status_mail_notification_level, material_icon='notifications') }}
</div>
<div class="card-action">
<div class="right-align">
{{ wtf.render_field(edit_notification_settings_form.submit, material_icon='send') }}
</div>
</div>
</div>
</form>
<form method="POST">
{{ change_password_form.hidden_tag() }}
<div class="card">
<div class="card-content">
<span class="card-title">Change Password</span>
{{ wtf.render_field(change_password_form.password, material_icon='vpn_key') }}
{{ wtf.render_field(change_password_form.new_password, material_icon='vpn_key') }}
{{ wtf.render_field(change_password_form.new_password_2, material_icon='vpn_key') }}
</div>
<div class="card-action">
<div class="right-align">
{{ wtf.render_field(change_password_form.submit, material_icon='send') }}
</div>
</div>
</div>
</form>
<div class="card">
<div class="card-content">
<span class="card-title">Delete account</span>
<p>Deleting an account has the following effects:</p>
<ul>
<li>All data associated with your corpora and jobs will be permanently deleted.</li>
<li>All settings will be permanently deleted.</li>
</ul>
</div>
<div class="card-action right-align">
<a class="btn red waves-effect waves-light" id="delete-user"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>
</div>
</div>
</div>
{% endblock page_content %}
{% block scripts %}
{{ super() }}
<script>
document.querySelector('#delete-user').addEventListener('click', (event) => {
Utils.deleteUserRequest(currentUserId)
.then((response) => {window.location.href = '/';});
});
</script>
{% endblock scripts %}

View File

@ -7,10 +7,11 @@
<div class="col s12"> <div class="col s12">
<h1 id="title">{{ title }}</h1> <h1 id="title">{{ title }}</h1>
</div> </div>
</div>
<div class="col s12"> <div class="row">
<div class="col s12 m9 l10">
<div class="card"> <div id="user-settings" class="card section scrollspy">
<form method="POST" enctype="multipart/form-data"> <form method="POST" enctype="multipart/form-data">
<div class="card-content"> <div class="card-content">
<div class="row"> <div class="row">
@ -31,7 +32,7 @@
<form method="POST"> <form method="POST">
{{ edit_privacy_settings_form.hidden_tag() }} {{ edit_privacy_settings_form.hidden_tag() }}
<div class="card"> <div id="privacy-settings" class="card section scrollspy">
<div class="card-content"> <div class="card-content">
<span class="card-title">Privacy settings</span> <span class="card-title">Privacy settings</span>
<br> <br>
@ -54,7 +55,7 @@
</div> </div>
</form> </form>
<div class="card"> <div id="public-profile-settings" class="card section scrollspy">
<form method="POST" enctype="multipart/form-data"> <form method="POST" enctype="multipart/form-data">
<div class="card-content"> <div class="card-content">
{{ edit_public_profile_information_form.hidden_tag() }} {{ edit_public_profile_information_form.hidden_tag() }}
@ -79,7 +80,7 @@
<div class="row"> <div class="row">
<div class="col s2"> <div class="col s2">
<div class="center-align"> <div class="center-align">
<a class="action-button btn-floating red waves-effect waves-light" style="margin-top:15px;" id="delete-avatar"><i class="material-icons">delete</i></a> <a class="btn-floating red waves-effect waves-light modal-trigger" style="margin-top:15px;" href="#delete-avatar-modal"><i class="material-icons">delete</i></a>
</div> </div>
</div> </div>
<div class="col s10"> <div class="col s10">
@ -106,49 +107,86 @@
</form> </form>
</div> </div>
<form method="POST">
{{ edit_notification_settings_form.hidden_tag() }}
<div id="notification-settings" class="card section scrollspy">
<div class="card-content">
<span class="card-title">Notification settings</span>
{{ wtf.render_field(edit_notification_settings_form.job_status_mail_notification_level, material_icon='notifications') }}
</div>
<div class="card-action">
<div class="right-align">
{{ wtf.render_field(edit_notification_settings_form.submit, material_icon='send') }}
</div>
</div>
</div>
</form>
<form method="POST">
{{ change_password_form.hidden_tag() }}
<div id="change-password" class="card section scrollspy">
<div class="card-content">
<span class="card-title">Change Password</span>
{{ wtf.render_field(change_password_form.password, material_icon='vpn_key') }}
{{ wtf.render_field(change_password_form.new_password, material_icon='vpn_key') }}
{{ wtf.render_field(change_password_form.new_password_2, material_icon='vpn_key') }}
</div>
<div class="card-action">
<div class="right-align">
{{ wtf.render_field(change_password_form.submit, material_icon='send') }}
</div>
</div>
</div>
</form>
<div id="delete-account" class="card section scrollspy">
<div class="card-content">
<span class="card-title">Delete account</span>
<p>Deleting an account has the following effects:</p>
<ul>
<li>All data associated with your corpora and jobs will be permanently deleted.</li>
<li>All settings will be permanently deleted.</li>
</ul>
</div>
<div class="card-action right-align">
<a class="btn red waves-effect waves-light modal-trigger" href="#delete-user"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>
</div>
<div class="col hide-on-small-only m3 l2">
<ul class="section table-of-contents" style="position: fixed !important;">
<li><a href="#user-settings">User Settings</a></li>
<li><a href="#privacy-settings">Privacy Settings</a></li>
<li><a href="#public-profile-settings">Public Profile Settings</a></li>
<li><a href="#notification-settings">Notification Settings</a></li>
<li><a href="#change-password">Change Password</a></li>
<li><a href="#delete-account">Delete Account</a></li>
</ul>
</div> </div>
</div> </div>
</div> </div>
{% endblock page_content %} {% endblock page_content %}
{% block scripts %} {% block modals %}
{{ super() }} <div class="modal" id="delete-avatar-modal">
<script> <div class="modal-content">
let publicProfile = document.querySelector('#public-profile'); <h4>Confirm Avatar deletion</h4>
let disableButtons = document.querySelectorAll('[data-action="disable"]'); <p>Do you really want to delete your Avatar?</p>
let deleteButton = document.querySelector('#delete-avatar'); </div>
let avatar = document.querySelector('#avatar'); <div class="modal-footer">
let avatarUpload = document.querySelector('#avatar-upload'); <a class="btn modal-close waves-effect waves-light">Cancel</a>
<a class="btn modal-close red waves-effect waves-light" id="delete-avatar">Delete</a>
for (let disableButton of disableButtons) { </div>
disableButton.disabled = !publicProfile.checked; </div>
} <div class="modal" id="delete-user">
<div class="modal-content">
publicProfile.addEventListener('change', () => { <h4>Confirm User deletion</h4>
if (publicProfile.checked) { <p>Do you really want to delete the User <b>{{ current_user.username }}</b>? All files will be permanently deleted!</p>
for (let disableButton of disableButtons) { </div>
disableButton.disabled = false; <div class="modal-footer">
} <a class="btn modal-close waves-effect waves-light">Cancel</a>
} else { <a class="btn modal-close red waves-effect waves-light" id="delete-user-button">Delete</a>
for (let disableButton of disableButtons) { </div>
disableButton.checked = false; </div>
disableButton.disabled = true; {% endblock modals %}
}
}
});
avatarUpload.addEventListener('change', function() {
let file = this.files[0];
avatar.src = URL.createObjectURL(file);
});
deleteButton.addEventListener('click', () => {
Utils.deleteProfileAvatarRequest({{ user.hashid|tojson }})
.then(
(response) => {
avatar.src = "{{ url_for('static', filename='images/user_avatar.png') }}";
}
);
});
</script>
{% endblock scripts %}

View File

@ -0,0 +1,41 @@
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') }}";
}
);
});
document.querySelector('#delete-user-button').addEventListener('click', (event) => {
Requests.users.entity.delete(currentUserId)
.then((response) => {window.location.href = '/';});
});

View File

@ -111,28 +111,3 @@
</div> </div>
{% endblock page_content %} {% endblock page_content %}
{% block scripts %}
{{ super() }}
<script>
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 }});
</script>
{% endblock scripts %}

View File

@ -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 }});

View File

@ -2,4 +2,5 @@ from flask import Blueprint
bp = Blueprint('users', __name__) bp = Blueprint('users', __name__)
from . import events, routes from . import events, routes, json_routes

View File

@ -2,6 +2,8 @@ from flask_wtf import FlaskForm
from wtforms import ( from wtforms import (
BooleanField, BooleanField,
FileField, FileField,
PasswordField,
SelectField,
StringField, StringField,
SubmitField, SubmitField,
TextAreaField, TextAreaField,
@ -10,10 +12,11 @@ from wtforms import (
from wtforms.validators import ( from wtforms.validators import (
DataRequired, DataRequired,
Email, Email,
EqualTo,
Length, Length,
Regexp Regexp
) )
from app.models import User from app.models import User, UserSettingJobStatusMailNotificationLevel
from app.auth import USERNAME_REGEX from app.auth import USERNAME_REGEX
from app.wtf_validators import FileSizeLimit from app.wtf_validators import FileSizeLimit
@ -98,3 +101,41 @@ class EditPrivacySettingsForm(FlaskForm):
show_last_seen = BooleanField('Show last seen') show_last_seen = BooleanField('Show last seen')
show_member_since = BooleanField('Show member since') show_member_since = BooleanField('Show member since')
submit = SubmitField() submit = SubmitField()
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()

54
app/users/json_routes.py Normal file
View File

@ -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('/<hashid:user_id>', 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('/<hashid:user_id>/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

View File

@ -16,6 +16,8 @@ from app import db
from app.models import Avatar, Corpus, ProfilePrivacySettings, User from app.models import Avatar, Corpus, ProfilePrivacySettings, User
from . import bp from . import bp
from .forms import ( from .forms import (
ChangePasswordForm,
EditNotificationSettingsForm,
EditPrivacySettingsForm, EditPrivacySettingsForm,
EditProfileSettingsForm, EditProfileSettingsForm,
EditPublicProfileInformationForm EditPublicProfileInformationForm
@ -51,24 +53,7 @@ def user(user_id):
user_id=user_id user_id=user_id
) )
@bp.route('/<hashid:user_id>', 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('/<hashid:user_id>/avatar') @bp.route('/<hashid:user_id>/avatar')
def profile_avatar(user_id): def profile_avatar(user_id):
@ -86,29 +71,12 @@ def profile_avatar(user_id):
) )
@bp.route('/<hashid:user_id>/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('/<hashid:user_id>/edit', methods=['GET', 'POST']) @bp.route('/<hashid:user_id>/edit', methods=['GET', 'POST'])
def edit_profile(user_id): def edit_profile(user_id):
user = User.query.get_or_404(user_id) user = User.query.get_or_404(user_id)
if not (user == current_user or current_user.is_administrator()): if not (user == current_user or current_user.is_administrator()):
abort(403) abort(403)
# region forms
edit_profile_settings_form = EditProfileSettingsForm( edit_profile_settings_form = EditProfileSettingsForm(
current_user, current_user,
data=current_user.to_json_serializeable(), data=current_user.to_json_serializeable(),
@ -122,12 +90,24 @@ def edit_profile(user_id):
data=current_user.to_json_serializeable(), data=current_user.to_json_serializeable(),
prefix='edit-public-profile-information-form' prefix='edit-public-profile-information-form'
) )
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'
)
# endregion forms
# region handle edit profile settings form
if edit_profile_settings_form.validate_on_submit(): if edit_profile_settings_form.validate_on_submit():
current_user.email = edit_profile_settings_form.email.data current_user.email = edit_profile_settings_form.email.data
current_user.username = edit_profile_settings_form.username.data current_user.username = edit_profile_settings_form.username.data
db.session.commit() db.session.commit()
flash('Profile settings updated') flash('Profile settings updated')
return redirect(url_for('.user', user_id=user.id)) return redirect(url_for('.user', user_id=user.id))
# endregion handle edit profile settings form
# region handle edit privacy settings form
if edit_privacy_settings_form.submit.data and edit_privacy_settings_form.validate(): if edit_privacy_settings_form.submit.data and edit_privacy_settings_form.validate():
current_user.is_public = edit_privacy_settings_form.is_public.data current_user.is_public = edit_privacy_settings_form.is_public.data
if edit_privacy_settings_form.show_email.data: if edit_privacy_settings_form.show_email.data:
@ -145,7 +125,9 @@ def edit_profile(user_id):
db.session.commit() db.session.commit()
flash('Your changes have been saved') flash('Your changes have been saved')
return redirect(url_for('.user', user_id=user.id)) return redirect(url_for('.user', user_id=user.id))
if edit_public_profile_information_form.validate_on_submit(): # endregion handle edit privacy settings form
# region handle edit public profile information form
if edit_public_profile_information_form.submit.data and edit_public_profile_information_form.validate():
if edit_public_profile_information_form.avatar.data: if edit_public_profile_information_form.avatar.data:
try: try:
Avatar.create(edit_public_profile_information_form.avatar.data, user=current_user) Avatar.create(edit_public_profile_information_form.avatar.data, user=current_user)
@ -159,11 +141,28 @@ def edit_profile(user_id):
db.session.commit() db.session.commit()
flash('Profile settings updated') flash('Profile settings updated')
return redirect(url_for('.user', user_id=user.id)) return redirect(url_for('.user', user_id=user.id))
# endregion handle edit public profile information 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('.edit_profile', user_id=user.id))
# 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('.edit_profile', user_id=user.id))
# endregion handle edit_notification_settings_form POST
return render_template( return render_template(
'users/edit_profile.html.j2', 'users/edit_profile.html.j2',
edit_profile_settings_form=edit_profile_settings_form, edit_profile_settings_form=edit_profile_settings_form,
edit_privacy_settings_form=edit_privacy_settings_form, edit_privacy_settings_form=edit_privacy_settings_form,
edit_public_profile_information_form=edit_public_profile_information_form, edit_public_profile_information_form=edit_public_profile_information_form,
change_password_form=change_password_form,
edit_notification_settings_form=edit_notification_settings_form,
user=user, user=user,
title='Edit Profile' title='Edit Profile'
) )