API & Client - user can delete his account - fix #17

This commit is contained in:
Sam 2020-05-01 16:18:59 +02:00
parent 3d01eadc71
commit f249b09146
14 changed files with 365 additions and 9 deletions

View File

@ -117,6 +117,15 @@ def user_2():
return user return user
@pytest.fixture()
def user_2_admin():
user = User(username='toto', email='toto@toto.com', password='87654321')
user.admin = True
db.session.add(user)
db.session.commit()
return user
@pytest.fixture() @pytest.fixture()
def user_3(): def user_3():
user = User(username='sam', email='sam@test.com', password='12345678') user = User(username='sam', email='sam@test.com', password='12345678')

View File

@ -1,4 +1,5 @@
import json import json
from io import BytesIO
from fittrackee_api.users.models import User from fittrackee_api.users.models import User
@ -409,3 +410,186 @@ def test_it_returns_error_if_user_can_not_change_admin_rights(
assert response.status_code == 403 assert response.status_code == 403
assert 'error' in data['status'] assert 'error' in data['status']
assert 'You do not have permissions.' in data['message'] assert 'You do not have permissions.' in data['message']
def test_user_can_delete_its_own_account(app, user_1):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.delete(
'/api/users/test',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
assert response.status_code == 204
def test_user_with_activity_can_delete_its_own_account(
app, user_1, sport_1_cycling, gpx_file
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
client.post(
'/api/activities',
data=dict(
file=(BytesIO(str.encode(gpx_file)), 'example.gpx'),
data='{"sport_id": 1}',
),
headers=dict(
content_type='multipart/form-data',
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token'],
),
)
response = client.delete(
'/api/users/test',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
assert response.status_code == 204
def test_user_with_picture_can_delete_its_own_account(
app, user_1, sport_1_cycling, gpx_file
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
client.post(
'/api/auth/picture',
data=dict(file=(BytesIO(b'avatar'), 'avatar.png')),
headers=dict(
content_type='multipart/form-data',
authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token'],
),
)
response = client.delete(
'/api/users/test',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
assert response.status_code == 204
def test_user_can_not_delete_another_user_account(app, user_1, user_2):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.delete(
'/api/users/toto',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 403
assert 'error' in data['status']
assert 'You do not have permissions.' in data['message']
def test_it_returns_error_when_deleting_non_existing_user(app, user_1):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.delete(
'/api/users/not_existing',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 404
assert 'not found' in data['status']
assert 'User does not exist.' in data['message']
def test_admin_can_delete_another_user_account(app, user_1_admin, user_2):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='admin@example.com', password='12345678')),
content_type='application/json',
)
response = client.delete(
'/api/users/toto',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
assert response.status_code == 204
def test_admin_can_delete_its_own_account(app, user_1_admin, user_2_admin):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='admin@example.com', password='12345678')),
content_type='application/json',
)
response = client.delete(
'/api/users/admin',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
assert response.status_code == 204
def test_admin_can_not_delete_its_own_account_if_no_other_admin(
app, user_1_admin, user_2
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='admin@example.com', password='12345678')),
content_type='application/json',
)
response = client.delete(
'/api/users/admin',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 403
assert 'error' in data['status']
assert (
'You can not delete your account, no other user has admin rights.'
in data['message']
)

View File

@ -1,9 +1,12 @@
import os
import shutil
from fittrackee_api import appLog, db from fittrackee_api import appLog, db
from flask import Blueprint, jsonify, request, send_file from flask import Blueprint, jsonify, request, send_file
from sqlalchemy import exc from sqlalchemy import exc
from ..activities.utils_files import get_absolute_file_path from ..activities.utils_files import get_absolute_file_path
from .models import User from .models import Activity, User
from .utils import authenticate, authenticate_as_admin from .utils import authenticate, authenticate_as_admin
users_blueprint = Blueprint('users', __name__) users_blueprint = Blueprint('users', __name__)
@ -270,7 +273,7 @@ def update_user(auth_user_id, user_name):
} }
:param integer auth_user_id: authenticate user id (from JSON Web Token) :param integer auth_user_id: authenticate user id (from JSON Web Token)
:param integer user_name: user name :param string user_name: user name
:<json boolean admin: does the user have administrator rights :<json boolean admin: does the user have administrator rights
@ -316,6 +319,111 @@ def update_user(auth_user_id, user_name):
return jsonify(response_object), code return jsonify(response_object), code
@users_blueprint.route('/users/<user_name>', methods=['DELETE'])
@authenticate
def delete_activity(auth_user_id, user_name):
"""
Delete a user account
- a user can only delete his own account
- an admin can delete all accounts except his account if he's the only
one admin
**Example request**:
.. sourcecode:: http
DELETE /api/users/john_doe HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 NO CONTENT
Content-Type: application/json
:param integer auth_user_id: authenticate user id (from JSON Web Token)
:param string user_name: user name
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 204: user account deleted
:statuscode 401:
- Provide a valid auth token.
- Signature expired. Please log in again.
- Invalid token. Please log in again.
:statuscode 403:
- You do not have permissions.
- You can not delete your account, no other user has admin rights.
:statuscode 404:
- User does not exist.
:statuscode 500: Error. Please try again or contact the administrator.
"""
try:
auth_user = User.query.filter_by(id=auth_user_id).first()
user = User.query.filter_by(username=user_name).first()
if user:
if user.id != auth_user_id and not auth_user.admin:
response_object = {
'status': 'error',
'message': 'You do not have permissions.',
}
return response_object, 403
if (
user.admin is True
and User.query.filter_by(admin=True).count() == 1
):
response_object = {
'status': 'error',
'message': (
'You can not delete your account, '
'no other user has admin rights.'
),
}
return response_object, 403
for activity in Activity.query.filter_by(user_id=user.id).all():
db.session.delete(activity)
db.session.flush()
user_picture = user.picture
db.session.delete(user)
db.session.commit()
if user_picture:
picture_path = get_absolute_file_path(user.picture)
if os.path.isfile(picture_path):
os.remove(picture_path)
shutil.rmtree(
get_absolute_file_path(f'activities/{user.id}'),
ignore_errors=True,
)
shutil.rmtree(
get_absolute_file_path(f'pictures/{user.id}'),
ignore_errors=True,
)
response_object = {'status': 'no content'}
code = 204
else:
response_object = {
'status': 'not found',
'message': 'User does not exist.',
}
code = 404
except (
exc.IntegrityError,
exc.OperationalError,
ValueError,
OSError,
) as e:
db.session.rollback()
appLog.error(e)
response_object = {
'status': 'error',
'message': 'Error. Please try again or contact the administrator.',
}
code = 500
return jsonify(response_object), code
@users_blueprint.route('/ping', methods=['GET']) @users_blueprint.route('/ping', methods=['GET'])
def ping_pong(): def ping_pong():
""" health check endpoint """ health check endpoint

View File

@ -2,7 +2,7 @@ import FitTrackeeGenericApi from '../fitTrackeeApi'
import FitTrackeeApi from '../fitTrackeeApi/auth' import FitTrackeeApi from '../fitTrackeeApi/auth'
import { history } from '../index' import { history } from '../index'
import { generateIds } from '../utils' import { generateIds } from '../utils'
import { getOrUpdateData, updateLanguage } from './index' import { getOrUpdateData, setError, updateLanguage } from './index'
const AuthError = message => ({ type: 'AUTH_ERROR', message }) const AuthError = message => ({ type: 'AUTH_ERROR', message })
@ -133,3 +133,15 @@ export const deletePicture = () => dispatch =>
.catch(error => { .catch(error => {
throw error throw error
}) })
export const deleteUser = username => dispatch =>
FitTrackeeGenericApi.deleteData('users', username)
.then(ret => {
if (ret.status === 204) {
dispatch(logout())
history.push('/')
} else {
ret.json().then(r => dispatch(setError(`${r.message}`)))
}
})
.catch(error => dispatch(setError(`user|${error}`)))

View File

@ -91,7 +91,7 @@ class ActivityDisplay extends React.Component {
<div className="container"> <div className="container">
{displayModal && ( {displayModal && (
<CustomModal <CustomModal
title={t('activities:Confirmation')} title={t('common:Confirmation')}
text={t( text={t(
'activities:Are you sure you want to delete this activity?' 'activities:Are you sure you want to delete this activity?'
)} )}

View File

@ -6,15 +6,17 @@ import { connect } from 'react-redux'
import TimezonePicker from 'react-timezone' import TimezonePicker from 'react-timezone'
import Message from '../Common/Message' import Message from '../Common/Message'
import { handleProfileFormSubmit } from '../../actions/user' import { deleteUser, handleProfileFormSubmit } from '../../actions/user'
import { history } from '../../index' import { history } from '../../index'
import { languages } from '../NavBar/LanguageDropdown' import { languages } from '../NavBar/LanguageDropdown'
import CustomModal from '../Common/CustomModal'
class ProfileEdit extends React.Component { class ProfileEdit extends React.Component {
constructor(props, context) { constructor(props, context) {
super(props, context) super(props, context)
this.state = { this.state = {
formData: {}, formData: {},
displayModal: false,
} }
} }
@ -51,9 +53,22 @@ class ProfileEdit extends React.Component {
this.setState(formData) this.setState(formData)
} }
displayModal(value) {
this.setState(prevState => ({
...prevState,
displayModal: value,
}))
}
render() { render() {
const { onHandleProfileFormSubmit, message, t, user } = this.props const {
const { formData } = this.state message,
onDeleteUser,
onHandleProfileFormSubmit,
t,
user,
} = this.props
const { displayModal, formData } = this.state
return ( return (
<div> <div>
<Helmet> <Helmet>
@ -62,6 +77,20 @@ class ProfileEdit extends React.Component {
<Message message={message} t={t} /> <Message message={message} t={t} />
{formData.isAuthenticated && ( {formData.isAuthenticated && (
<div className="container"> <div className="container">
{displayModal && (
<CustomModal
title={t('common:Confirmation')}
text={t(
'user:Are you sure you want to delete your account? ' +
'All data will be deleted, this cannot be undone.'
)}
confirm={() => {
onDeleteUser(user.username)
this.displayModal(false)
}}
close={() => this.displayModal(false)}
/>
)}
<h1 className="page-title">{t('user:Profile Edition')}</h1> <h1 className="page-title">{t('user:Profile Edition')}</h1>
<div className="row"> <div className="row">
<div className="col-md-2" /> <div className="col-md-2" />
@ -242,6 +271,11 @@ class ProfileEdit extends React.Component {
className="btn btn-primary btn-lg btn-block" className="btn btn-primary btn-lg btn-block"
value={t('common:Submit')} value={t('common:Submit')}
/> />
<input
className="btn btn-danger btn-lg btn-block"
onClick={() => this.displayModal(true)}
defaultValue={t('user:Delete my account')}
/>
<input <input
type="submit" type="submit"
className="btn btn-secondary btn-lg btn-block" className="btn btn-secondary btn-lg btn-block"
@ -271,6 +305,9 @@ export default withTranslation()(
user: state.user, user: state.user,
}), }),
dispatch => ({ dispatch => ({
onDeleteUser: username => {
dispatch(deleteUser(username))
},
onHandleProfileFormSubmit: formData => { onHandleProfileFormSubmit: formData => {
dispatch(handleProfileFormSubmit(formData)) dispatch(handleProfileFormSubmit(formData))
}, },

View File

@ -8,7 +8,6 @@
"Ascent": "Ascent", "Ascent": "Ascent",
"Average speed": "Average speed", "Average speed": "Average speed",
"Chart": "Chart", "Chart": "Chart",
"Confirmation": "Confirmation",
"data from gpx, without any cleaning": "data from gpx, without any cleaning", "data from gpx, without any cleaning": "data from gpx, without any cleaning",
"Date": "Date", "Date": "Date",
"Delete activity": "Delete activity", "Delete activity": "Delete activity",

View File

@ -2,6 +2,7 @@
"Add workout": "Add workout", "Add workout": "Add workout",
"Back": "Back", "Back": "Back",
"Cancel": "Cancel", "Cancel": "Cancel",
"Confirmation": "Confirmation",
"Dashboard": "Dashboard", "Dashboard": "Dashboard",
"Edit": "Edit", "Edit": "Edit",
"day": "day", "day": "day",

View File

@ -30,5 +30,6 @@
"statistics": "statistiques", "statistics": "statistiques",
"User does not exist.": "User does not exist.", "User does not exist.": "User does not exist.",
"Valid email must be provided.\n": "Valid email must be provided.", "Valid email must be provided.\n": "Valid email must be provided.",
"You can not delete your account, no other user has admin rights.": "You can not delete your account, no other user has admin rights.",
"You do not have permissions.": "You do not have permissions." "You do not have permissions.": "You do not have permissions."
} }

View File

@ -1,7 +1,9 @@
{ {
"Admin": "Admin", "Admin": "Admin",
"Are you sure you want to delete your account? All data will be deleted, this cannot be undone.": "Are you sure you want to delete your account? All data will be deleted, this cannot be undone.",
"Bio": "Bio", "Bio": "Bio",
"Birth Date": "Birth Date", "Birth Date": "Birth Date",
"Delete my account": "Delete my account",
"Delete picture": "Delete picture", "Delete picture": "Delete picture",
"Edit Profile": "Edit Profile", "Edit Profile": "Edit Profile",
"Email": "Email", "Email": "Email",

View File

@ -8,7 +8,6 @@
"Ascent": "Dénivelé positif", "Ascent": "Dénivelé positif",
"Average speed": "Vitesse moyenne", "Average speed": "Vitesse moyenne",
"Chart": "Analyse", "Chart": "Analyse",
"Confirmation": "Confirmation",
"data from gpx, without any cleaning": "données issues du fichier gpx, sans correction", "data from gpx, without any cleaning": "données issues du fichier gpx, sans correction",
"Date": "Date", "Date": "Date",
"Delete activity": "Supprimer l'activité", "Delete activity": "Supprimer l'activité",

View File

@ -2,6 +2,7 @@
"Add workout": "Ajouter une activité", "Add workout": "Ajouter une activité",
"Back": "Revenir à la page précédente", "Back": "Revenir à la page précédente",
"Cancel": "Annuler", "Cancel": "Annuler",
"Confirmation": "Confirmation",
"Dashboard": "Tableau de Bord", "Dashboard": "Tableau de Bord",
"Edit": "Modifier", "Edit": "Modifier",
"day": "jour", "day": "jour",

View File

@ -30,5 +30,6 @@
"statistics": "statistics", "statistics": "statistics",
"User does not exist.": "L'utilisateur n'existe pas.", "User does not exist.": "L'utilisateur n'existe pas.",
"Valid email must be provided.\n": "L'email fourni n'est pas valide.", "Valid email must be provided.\n": "L'email fourni n'est pas valide.",
"You can not delete your account, no other user has admin rights.": "Vous ne pouvez pas supprimer votre compte, aucun autre utilisateur n'a des droits d'administration.",
"You do not have permissions.": "Vous n'avez pas les permissions nécessaires." "You do not have permissions.": "Vous n'avez pas les permissions nécessaires."
} }

View File

@ -1,7 +1,9 @@
{ {
"Admin": "Admin", "Admin": "Admin",
"Are you sure you want to delete your account? All data will be deleted, this cannot be undone.": "Etes-vous sûr de vouloir supprimer votre compte ? Toutes les données seront définitivement effacés.",
"Bio": "Bio", "Bio": "Bio",
"Birth Date": "Date de naissance", "Birth Date": "Date de naissance",
"Delete my account": "Supprimer mon compte",
"Delete picture": "Supprimer l'image", "Delete picture": "Supprimer l'image",
"Edit Profile": "Editer le profil", "Edit Profile": "Editer le profil",
"Email": "Email", "Email": "Email",