API & Client - user can delete his account - fix #17
This commit is contained in:
parent
3d01eadc71
commit
f249b09146
@ -117,6 +117,15 @@ def user_2():
|
||||
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()
|
||||
def user_3():
|
||||
user = User(username='sam', email='sam@test.com', password='12345678')
|
||||
|
@ -1,4 +1,5 @@
|
||||
import json
|
||||
from io import BytesIO
|
||||
|
||||
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 'error' in data['status']
|
||||
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']
|
||||
)
|
||||
|
@ -1,9 +1,12 @@
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from fittrackee_api import appLog, db
|
||||
from flask import Blueprint, jsonify, request, send_file
|
||||
from sqlalchemy import exc
|
||||
|
||||
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
|
||||
|
||||
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 user_name: user name
|
||||
:param string user_name: user name
|
||||
|
||||
:<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
|
||||
|
||||
|
||||
@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'])
|
||||
def ping_pong():
|
||||
""" health check endpoint
|
||||
|
@ -2,7 +2,7 @@ import FitTrackeeGenericApi from '../fitTrackeeApi'
|
||||
import FitTrackeeApi from '../fitTrackeeApi/auth'
|
||||
import { history } from '../index'
|
||||
import { generateIds } from '../utils'
|
||||
import { getOrUpdateData, updateLanguage } from './index'
|
||||
import { getOrUpdateData, setError, updateLanguage } from './index'
|
||||
|
||||
const AuthError = message => ({ type: 'AUTH_ERROR', message })
|
||||
|
||||
@ -133,3 +133,15 @@ export const deletePicture = () => dispatch =>
|
||||
.catch(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}`)))
|
||||
|
@ -91,7 +91,7 @@ class ActivityDisplay extends React.Component {
|
||||
<div className="container">
|
||||
{displayModal && (
|
||||
<CustomModal
|
||||
title={t('activities:Confirmation')}
|
||||
title={t('common:Confirmation')}
|
||||
text={t(
|
||||
'activities:Are you sure you want to delete this activity?'
|
||||
)}
|
||||
|
@ -6,15 +6,17 @@ import { connect } from 'react-redux'
|
||||
import TimezonePicker from 'react-timezone'
|
||||
|
||||
import Message from '../Common/Message'
|
||||
import { handleProfileFormSubmit } from '../../actions/user'
|
||||
import { deleteUser, handleProfileFormSubmit } from '../../actions/user'
|
||||
import { history } from '../../index'
|
||||
import { languages } from '../NavBar/LanguageDropdown'
|
||||
import CustomModal from '../Common/CustomModal'
|
||||
|
||||
class ProfileEdit extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
formData: {},
|
||||
displayModal: false,
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,9 +53,22 @@ class ProfileEdit extends React.Component {
|
||||
this.setState(formData)
|
||||
}
|
||||
|
||||
displayModal(value) {
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
displayModal: value,
|
||||
}))
|
||||
}
|
||||
|
||||
render() {
|
||||
const { onHandleProfileFormSubmit, message, t, user } = this.props
|
||||
const { formData } = this.state
|
||||
const {
|
||||
message,
|
||||
onDeleteUser,
|
||||
onHandleProfileFormSubmit,
|
||||
t,
|
||||
user,
|
||||
} = this.props
|
||||
const { displayModal, formData } = this.state
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
@ -62,6 +77,20 @@ class ProfileEdit extends React.Component {
|
||||
<Message message={message} t={t} />
|
||||
{formData.isAuthenticated && (
|
||||
<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>
|
||||
<div className="row">
|
||||
<div className="col-md-2" />
|
||||
@ -242,6 +271,11 @@ class ProfileEdit extends React.Component {
|
||||
className="btn btn-primary btn-lg btn-block"
|
||||
value={t('common:Submit')}
|
||||
/>
|
||||
<input
|
||||
className="btn btn-danger btn-lg btn-block"
|
||||
onClick={() => this.displayModal(true)}
|
||||
defaultValue={t('user:Delete my account')}
|
||||
/>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-secondary btn-lg btn-block"
|
||||
@ -271,6 +305,9 @@ export default withTranslation()(
|
||||
user: state.user,
|
||||
}),
|
||||
dispatch => ({
|
||||
onDeleteUser: username => {
|
||||
dispatch(deleteUser(username))
|
||||
},
|
||||
onHandleProfileFormSubmit: formData => {
|
||||
dispatch(handleProfileFormSubmit(formData))
|
||||
},
|
||||
|
@ -8,7 +8,6 @@
|
||||
"Ascent": "Ascent",
|
||||
"Average speed": "Average speed",
|
||||
"Chart": "Chart",
|
||||
"Confirmation": "Confirmation",
|
||||
"data from gpx, without any cleaning": "data from gpx, without any cleaning",
|
||||
"Date": "Date",
|
||||
"Delete activity": "Delete activity",
|
||||
|
@ -2,6 +2,7 @@
|
||||
"Add workout": "Add workout",
|
||||
"Back": "Back",
|
||||
"Cancel": "Cancel",
|
||||
"Confirmation": "Confirmation",
|
||||
"Dashboard": "Dashboard",
|
||||
"Edit": "Edit",
|
||||
"day": "day",
|
||||
|
@ -30,5 +30,6 @@
|
||||
"statistics": "statistiques",
|
||||
"User does not exist.": "User does not exist.",
|
||||
"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."
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
{
|
||||
"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",
|
||||
"Birth Date": "Birth Date",
|
||||
"Delete my account": "Delete my account",
|
||||
"Delete picture": "Delete picture",
|
||||
"Edit Profile": "Edit Profile",
|
||||
"Email": "Email",
|
||||
|
@ -8,7 +8,6 @@
|
||||
"Ascent": "Dénivelé positif",
|
||||
"Average speed": "Vitesse moyenne",
|
||||
"Chart": "Analyse",
|
||||
"Confirmation": "Confirmation",
|
||||
"data from gpx, without any cleaning": "données issues du fichier gpx, sans correction",
|
||||
"Date": "Date",
|
||||
"Delete activity": "Supprimer l'activité",
|
||||
|
@ -2,6 +2,7 @@
|
||||
"Add workout": "Ajouter une activité",
|
||||
"Back": "Revenir à la page précédente",
|
||||
"Cancel": "Annuler",
|
||||
"Confirmation": "Confirmation",
|
||||
"Dashboard": "Tableau de Bord",
|
||||
"Edit": "Modifier",
|
||||
"day": "jour",
|
||||
|
@ -30,5 +30,6 @@
|
||||
"statistics": "statistics",
|
||||
"User does not exist.": "L'utilisateur n'existe pas.",
|
||||
"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."
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
{
|
||||
"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",
|
||||
"Birth Date": "Date de naissance",
|
||||
"Delete my account": "Supprimer mon compte",
|
||||
"Delete picture": "Supprimer l'image",
|
||||
"Edit Profile": "Editer le profil",
|
||||
"Email": "Email",
|
||||
|
Loading…
Reference in New Issue
Block a user