FitTrackee/fittrackee/users/users.py

682 lines
20 KiB
Python
Raw Normal View History

import os
import random
import secrets
import shutil
2021-01-02 19:28:03 +01:00
from typing import Any, Dict, Tuple, Union
import click
from flask import Blueprint, current_app, request, send_file
2021-01-20 16:47:00 +01:00
from sqlalchemy import exc
from fittrackee import bcrypt, db
from fittrackee.emails.tasks import (
email_updated_to_new_address,
password_change_email,
reset_password_email,
)
2022-02-16 12:55:55 +01:00
from fittrackee.files import get_absolute_file_path
2021-01-01 16:39:25 +01:00
from fittrackee.responses import (
ForbiddenErrorResponse,
2021-01-02 19:28:03 +01:00
HttpResponse,
2021-01-01 16:39:25 +01:00
InvalidPayloadErrorResponse,
NotFoundErrorResponse,
UserNotFoundErrorResponse,
handle_error_and_return_response,
)
from fittrackee.users.utils.controls import is_valid_email
from fittrackee.utils import get_readable_duration
from fittrackee.workouts.models import Record, Workout, WorkoutSegment
2017-12-16 21:00:46 +01:00
2021-01-20 16:24:01 +01:00
from .decorators import authenticate, authenticate_as_admin
from .exceptions import UserNotFoundException
from .models import User, UserSportPreference
2022-02-16 18:07:05 +01:00
from .utils.admin import set_admin_rights
from .utils.random import random_string
2017-12-16 21:00:46 +01:00
users_blueprint = Blueprint('users', __name__)
USER_PER_PAGE = 10
2017-12-16 21:00:46 +01:00
@users_blueprint.cli.command('set-admin')
@click.argument('username')
def set_admin(username: str) -> None:
"""Set admin rights for given user"""
try:
set_admin_rights(username)
print(f"User '{username}' updated.")
except UserNotFoundException:
print(f"User '{username}' not found.")
2017-12-16 21:00:46 +01:00
@users_blueprint.route('/users', methods=['GET'])
@authenticate_as_admin
def get_users(auth_user: User) -> Dict:
"""
Get all users
**Example request**:
- without parameters
.. sourcecode:: http
2020-05-03 11:30:40 +02:00
GET /api/users HTTP/1.1
Content-Type: application/json
- with some query parameters
.. sourcecode:: http
GET /api/users?order_by=workouts_count&par_page=5 HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": {
"users": [
{
"admin": true,
"bio": null,
"birth_date": null,
"created_at": "Sun, 14 Jul 2019 14:09:58 GMT",
"email": "admin@example.com",
"first_name": null,
"imperial_units": false,
2019-12-29 12:50:32 +01:00
"language": "en",
"last_name": null,
"location": null,
"nb_sports": 3,
"nb_workouts": 6,
"picture": false,
2021-09-21 18:10:27 +02:00
"records": [
{
"id": 9,
"record_type": "AS",
"sport_id": 1,
"user": "admin",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 10,
"record_type": "FD",
"sport_id": 1,
"user": "admin",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 11,
"record_type": "LD",
"sport_id": 1,
"user": "admin",
"value": "1:01:00",
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 12,
"record_type": "MS",
"sport_id": 1,
"user": "admin",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
}
],
"sports_list": [
1,
4,
6
],
"timezone": "Europe/Paris",
"total_distance": 67.895,
"total_duration": "6:50:27",
"username": "admin"
},
{
"admin": false,
"bio": null,
"birth_date": null,
"created_at": "Sat, 20 Jul 2019 11:27:03 GMT",
"email": "sam@example.com",
"first_name": null,
2019-12-29 12:50:32 +01:00
"language": "fr",
"last_name": null,
"location": null,
"nb_sports": 0,
"nb_workouts": 0,
"picture": false,
2021-09-21 18:10:27 +02:00
"records": [],
"sports_list": [],
"timezone": "Europe/Paris",
"total_distance": 0,
"total_duration": "0:00:00",
"username": "sam"
}
]
},
"status": "success"
}
:query integer page: page if using pagination (default: 1)
:query integer per_page: number of users per page (default: 10, max: 50)
:query string q: query on user name
:query string order_by: sorting criteria (``username``, ``created_at``,
``workouts_count``, ``admin``)
:query string order: sorting order (default: ``asc``)
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: success
:statuscode 401:
- provide a valid auth token
- signature expired, please log in again
- invalid token, please log in again
"""
params = request.args.copy()
2021-05-22 17:14:24 +02:00
page = int(params.get('page', 1))
per_page = int(params.get('per_page', USER_PER_PAGE))
if per_page > 50:
per_page = 50
order_by = params.get('order_by')
order = params.get('order', 'asc')
query = params.get('q')
users_pagination = (
User.query.filter(
User.username.like('%' + query + '%') if query else True,
)
.order_by(
User.workouts_count.asc() # type: ignore
if order_by == 'workouts_count' and order == 'asc'
else True,
User.workouts_count.desc() # type: ignore
if order_by == 'workouts_count' and order == 'desc'
else True,
User.username.asc()
if order_by == 'username' and order == 'asc'
else True,
User.username.desc()
if order_by == 'username' and order == 'desc'
else True,
User.created_at.asc()
if order_by == 'created_at' and order == 'asc'
else True,
User.created_at.desc()
if order_by == 'created_at' and order == 'desc'
else True,
User.admin.asc()
if order_by == 'admin' and order == 'asc'
else True,
User.admin.desc()
if order_by == 'admin' and order == 'desc'
else True,
)
.paginate(page, per_page, False)
)
users = users_pagination.items
2021-01-01 16:39:25 +01:00
return {
2017-12-16 21:00:46 +01:00
'status': 'success',
'data': {'users': [user.serialize(auth_user) for user in users]},
'pagination': {
'has_next': users_pagination.has_next,
'has_prev': users_pagination.has_prev,
'page': users_pagination.page,
'pages': users_pagination.pages,
'total': users_pagination.total,
},
2017-12-16 21:00:46 +01:00
}
@users_blueprint.route('/users/<user_name>', methods=['GET'])
@authenticate_as_admin
2021-01-02 19:28:03 +01:00
def get_single_user(
auth_user: User, user_name: str
2021-01-02 19:28:03 +01:00
) -> Union[Dict, HttpResponse]:
"""
Get single user details
**Example request**:
.. sourcecode:: http
2020-02-08 14:49:37 +01:00
GET /api/users/admin HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": [
{
"admin": true,
"bio": null,
"birth_date": null,
"created_at": "Sun, 14 Jul 2019 14:09:58 GMT",
"email": "admin@example.com",
"first_name": null,
"imperial_units": false,
"language": "en",
"last_name": null,
"location": null,
"nb_sports": 3,
"nb_workouts": 6,
"picture": false,
2021-09-21 18:10:27 +02:00
"records": [
{
"id": 9,
"record_type": "AS",
"sport_id": 1,
"user": "admin",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 10,
"record_type": "FD",
"sport_id": 1,
"user": "admin",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 11,
"record_type": "LD",
"sport_id": 1,
"user": "admin",
"value": "1:01:00",
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 12,
"record_type": "MS",
"sport_id": 1,
"user": "admin",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
}
],
"sports_list": [
1,
4,
6
],
"timezone": "Europe/Paris",
"total_distance": 67.895,
"total_duration": "6:50:27",
"username": "admin"
}
],
"status": "success"
}
:param integer user_name: user name
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: success
:statuscode 401:
- provide a valid auth token
- signature expired, please log in again
- invalid token, please log in again
:statuscode 404:
- user does not exist
"""
2017-12-16 21:00:46 +01:00
try:
user = User.query.filter_by(username=user_name).first()
2021-01-01 16:39:25 +01:00
if user:
return {
'status': 'success',
'data': {'users': [user.serialize(auth_user)]},
}
2017-12-16 21:00:46 +01:00
except ValueError:
2021-01-01 16:39:25 +01:00
pass
return UserNotFoundErrorResponse()
2017-12-16 21:00:46 +01:00
2020-02-08 14:49:37 +01:00
@users_blueprint.route('/users/<user_name>/picture', methods=['GET'])
2021-01-02 19:28:03 +01:00
def get_picture(user_name: str) -> Any:
2020-09-16 11:09:32 +02:00
"""get user picture
**Example request**:
.. sourcecode:: http
2020-02-08 14:49:37 +01:00
GET /api/users/admin/picture HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: image/jpeg
2020-02-08 14:49:37 +01:00
:param integer user_name: user name
:statuscode 200: success
:statuscode 404:
- user does not exist
- No picture.
"""
2018-01-01 21:54:03 +01:00
try:
2020-02-08 14:49:37 +01:00
user = User.query.filter_by(username=user_name).first()
2018-01-01 21:54:03 +01:00
if not user:
2021-01-01 16:39:25 +01:00
return UserNotFoundErrorResponse()
if user.picture is not None:
picture_path = get_absolute_file_path(user.picture)
return send_file(picture_path)
except Exception:
2021-01-01 16:39:25 +01:00
pass
return NotFoundErrorResponse('No picture.')
2018-01-01 21:54:03 +01:00
@users_blueprint.route('/users/<user_name>', methods=['PATCH'])
@authenticate_as_admin
def update_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]:
"""
Update user to add admin rights
Only user with admin rights can modify another user
**Example request**:
.. sourcecode:: http
PATCH api/users/<user_name> HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": [
{
"admin": true,
"bio": null,
"birth_date": null,
"created_at": "Sun, 14 Jul 2019 14:09:58 GMT",
"email": "admin@example.com",
"first_name": null,
"imperial_units": false,
"language": "en",
"last_name": null,
"location": null,
"nb_workouts": 6,
"nb_sports": 3,
"picture": false,
2021-09-21 18:10:27 +02:00
"records": [
{
"id": 9,
"record_type": "AS",
"sport_id": 1,
"user": "admin",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 10,
"record_type": "FD",
"sport_id": 1,
"user": "admin",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 11,
"record_type": "LD",
"sport_id": 1,
"user": "admin",
"value": "1:01:00",
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 12,
"record_type": "MS",
"sport_id": 1,
"user": "admin",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
}
],
"sports_list": [
1,
4,
6
],
"timezone": "Europe/Paris",
"total_distance": 67.895,
"total_duration": "6:50:27",
"username": "admin"
}
],
"status": "success"
}
:param string user_name: user name
:<json boolean admin: does the user have administrator rights
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: success
: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
:statuscode 404:
- user does not exist
:statuscode 500:
"""
user_data = request.get_json()
if not user_data:
2021-01-01 16:39:25 +01:00
return InvalidPayloadErrorResponse()
send_password_emails = False
send_new_address_email = False
try:
user = User.query.filter_by(username=user_name).first()
if not user:
2021-01-01 16:39:25 +01:00
return UserNotFoundErrorResponse()
if 'admin' in user_data:
user.admin = user_data['admin']
if (
'reset_password' in user_data
and user_data['reset_password'] is True
):
new_password = random_string(length=random.randint(10, 20))
user.password = bcrypt.generate_password_hash(
new_password, current_app.config.get('BCRYPT_LOG_ROUNDS')
).decode()
send_password_emails = True
if 'new_email' in user_data:
if is_valid_email(user_data['new_email']):
user.email_to_confirm = user_data['new_email']
user.confirmation_token = secrets.token_urlsafe(16)
send_new_address_email = True
else:
return InvalidPayloadErrorResponse(
'valid email must be provided'
)
2021-01-01 16:39:25 +01:00
db.session.commit()
user_language = 'en' if user.language is None else user.language
ui_url = current_app.config['UI_URL']
if send_password_emails:
user_data = {
'language': user_language,
'email': user.email,
}
password_change_email.send(
user_data,
{
'username': user.username,
'fittrackee_url': ui_url,
},
)
password_reset_token = user.encode_password_reset_token(user.id)
reset_password_email.send(
user_data,
{
'expiration_delay': get_readable_duration(
current_app.config[
'PASSWORD_TOKEN_EXPIRATION_SECONDS'
],
user_language,
),
'username': user.username,
'password_reset_url': (
f'{ui_url}/password-reset?token={password_reset_token}'
),
'fittrackee_url': ui_url,
},
)
if send_new_address_email:
user_data = {
'language': user_language,
'email': user.email_to_confirm,
}
email_data = {
'username': user.username,
'fittrackee_url': ui_url,
'email_confirmation_url': (
f'{ui_url}/email-update'
f'?token={user.confirmation_token}'
),
}
email_updated_to_new_address.send(user_data, email_data)
2021-01-01 16:39:25 +01:00
return {
'status': 'success',
'data': {'users': [user.serialize(auth_user)]},
}
2021-01-01 16:39:25 +01:00
except exc.StatementError as e:
return handle_error_and_return_response(e, db=db)
@users_blueprint.route('/users/<user_name>', methods=['DELETE'])
@authenticate
2021-01-02 19:28:03 +01:00
def delete_user(
auth_user: User, user_name: str
2021-01-02 19:28:03 +01:00
) -> Union[Tuple[Dict, int], HttpResponse]:
"""
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 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:
user = User.query.filter_by(username=user_name).first()
2021-01-01 16:39:25 +01:00
if not user:
return UserNotFoundErrorResponse()
if user.id != auth_user.id and not auth_user.admin:
2021-01-01 16:39:25 +01:00
return ForbiddenErrorResponse()
if (
user.admin is True
and User.query.filter_by(admin=True).count() == 1
):
return ForbiddenErrorResponse(
'you can not delete your account, '
'no other user has admin rights'
)
2021-01-01 16:39:25 +01:00
db.session.query(UserSportPreference).filter(
UserSportPreference.user_id == user.id
).delete()
db.session.query(Record).filter(Record.user_id == user.id).delete()
db.session.query(WorkoutSegment).filter(
WorkoutSegment.workout_id == Workout.id, Workout.user_id == user.id
).delete(synchronize_session=False)
db.session.query(Workout).filter(Workout.user_id == user.id).delete()
db.session.flush()
2021-01-01 16:39:25 +01:00
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'workouts/{user.id}'),
2021-01-01 16:39:25 +01:00
ignore_errors=True,
)
shutil.rmtree(
get_absolute_file_path(f'pictures/{user.id}'),
ignore_errors=True,
)
return {'status': 'no content'}, 204
except (
exc.IntegrityError,
exc.OperationalError,
ValueError,
OSError,
) as e:
2021-01-01 16:39:25 +01:00
return handle_error_and_return_response(e, db=db)