API & Client - add pagination and filter on users lists

This commit is contained in:
Sam 2020-05-02 18:00:17 +02:00
parent 958c6711a3
commit 1c13aca2eb
17 changed files with 1154 additions and 126 deletions

3
.flake8 Normal file
View File

@ -0,0 +1,3 @@
[flake8]
per-file-ignores =
fittrackee_api/fittrackee_api/activities/stats.py:E501

View File

@ -31,6 +31,8 @@ from .utils_gpx import (
activities_blueprint = Blueprint('activities', __name__) activities_blueprint = Blueprint('activities', __name__)
ACTIVITIES_PER_PAGE = 5
@activities_blueprint.route('/activities', methods=['GET']) @activities_blueprint.route('/activities', methods=['GET'])
@authenticate @authenticate
@ -152,7 +154,8 @@ def get_activities(auth_user_id):
:param integer auth_user_id: authenticate user id (from JSON Web Token) :param integer auth_user_id: authenticate user id (from JSON Web Token)
:query integer page: page if using pagination (default: 1) :query integer page: page if using pagination (default: 1)
:query integer per_page: number of activities per page (default: 5) :query integer per_page: number of activities per page
(default: 5, max: 50)
:query integer sport_id: sport id :query integer sport_id: sport id
:query string from: start date (format: ``%Y-%m-%d``) :query string from: start date (format: ``%Y-%m-%d``)
:query string to: end date (format: ``%Y-%m-%d``) :query string to: end date (format: ``%Y-%m-%d``)
@ -200,7 +203,13 @@ def get_activities(auth_user_id):
max_speed_to = params.get('max_speed_to') max_speed_to = params.get('max_speed_to')
order = params.get('order') order = params.get('order')
sport_id = params.get('sport_id') sport_id = params.get('sport_id')
per_page = int(params.get('per_page')) if params.get('per_page') else 5 per_page = (
int(params.get('per_page'))
if params.get('per_page')
else ACTIVITIES_PER_PAGE
)
if per_page > 50:
per_page = 50
activities = ( activities = (
Activity.query.filter( Activity.query.filter(
Activity.user_id == auth_user_id, Activity.user_id == auth_user_id,

View File

@ -158,7 +158,7 @@ def get_activities_by_time(auth_user_id, user_name):
.. sourcecode:: http .. sourcecode:: http
GET /api/stats/admin/by_time?from=2018-01-01&to=2018-06-30&time=week HTTP/1.1 # noqa GET /api/stats/admin/by_time?from=2018-01-01&to=2018-06-30&time=week HTTP/1.1
**Example responses**: **Example responses**:
@ -346,7 +346,8 @@ def get_application_stats(auth_user_id):
"data": { "data": {
"activities": 3, "activities": 3,
"sports": 3, "sports": 3,
"users": 2 "users": 2,
"uploads_dir_size": 1000
}, },
"status": "success" "status": "success"
} }

View File

@ -143,7 +143,7 @@ def update_application_config(auth_user_id):
@config_blueprint.route('/ping', methods=['GET']) @config_blueprint.route('/ping', methods=['GET'])
def ping_pong(): def health_check():
""" health check endpoint """ health check endpoint
**Example request**: **Example request**:

View File

@ -200,3 +200,13 @@ def test_update_config_no_config(app_no_config, user_1_admin):
assert response.status_code == 500 assert response.status_code == 500
assert 'error' in data['status'] assert 'error' in data['status']
assert 'Error on updating configuration.' in data['message'] assert 'Error on updating configuration.' in data['message']
def test_ping(app):
""" => Ensure the /ping route behaves correctly."""
client = app.test_client()
response = client.get('/api/ping')
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'pong' in data['message']
assert 'success' in data['status']

View File

@ -1,19 +1,11 @@
import json import json
from datetime import datetime, timedelta
from io import BytesIO from io import BytesIO
from unittest.mock import patch
from fittrackee_api.users.models import User from fittrackee_api.users.models import User
def test_ping(app):
""" => Ensure the /ping route behaves correctly."""
client = app.test_client()
response = client.get('/api/ping')
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'pong' in data['message']
assert 'success' in data['status']
def test_single_user(app, user_1, user_2): def test_single_user(app, user_1, user_2):
"""=> Get single user details""" """=> Get single user details"""
client = app.test_client() client = app.test_client()
@ -183,6 +175,13 @@ def test_users_list(app, user_1, user_2, user_3):
assert data['data']['users'][2]['sports_list'] == [] assert data['data']['users'][2]['sports_list'] == []
assert data['data']['users'][2]['total_distance'] == 0 assert data['data']['users'][2]['total_distance'] == 0
assert data['data']['users'][2]['total_duration'] == '0:00:00' assert data['data']['users'][2]['total_duration'] == '0:00:00'
assert data['pagination'] == {
'has_next': False,
'has_prev': False,
'page': 1,
'pages': 1,
'total': 3,
}
def test_users_list_with_activities( def test_users_list_with_activities(
@ -246,6 +245,666 @@ def test_users_list_with_activities(
assert data['data']['users'][2]['sports_list'] == [] assert data['data']['users'][2]['sports_list'] == []
assert data['data']['users'][2]['total_distance'] == 0 assert data['data']['users'][2]['total_distance'] == 0
assert data['data']['users'][2]['total_duration'] == '0:00:00' assert data['data']['users'][2]['total_duration'] == '0:00:00'
assert data['pagination'] == {
'has_next': False,
'has_prev': False,
'page': 1,
'pages': 1,
'total': 3,
}
@patch('fittrackee_api.users.users.USER_PER_PAGE', 2)
def test_it_gets_first_page_on_users_list(
app, user_1, user_2, user_3,
):
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.get(
'/api/users?page=1',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['users']) == 2
assert data['pagination'] == {
'has_next': True,
'has_prev': False,
'page': 1,
'pages': 2,
'total': 3,
}
@patch('fittrackee_api.users.users.USER_PER_PAGE', 2)
def test_it_gets_next_page_on_users_list(
app, user_1, user_2, user_3,
):
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.get(
'/api/users?page=2',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['users']) == 1
assert data['pagination'] == {
'has_next': False,
'has_prev': True,
'page': 2,
'pages': 2,
'total': 3,
}
def test_it_gets_empty_next_page_on_users_list(
app, user_1, user_2, user_3,
):
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.get(
'/api/users?page=2',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['users']) == 0
assert data['pagination'] == {
'has_next': False,
'has_prev': True,
'page': 2,
'pages': 1,
'total': 3,
}
def test_it_gets_user_list_with_2_per_page(
app, user_1, user_2, user_3,
):
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.get(
'/api/users?per_page=2',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['users']) == 2
assert data['pagination'] == {
'has_next': True,
'has_prev': False,
'page': 1,
'pages': 2,
'total': 3,
}
def test_it_gets_next_page_on_user_list_with_2_per_page(
app, user_1, user_2, user_3,
):
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.get(
'/api/users?page=2&per_page=2',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['users']) == 1
assert data['pagination'] == {
'has_next': False,
'has_prev': True,
'page': 2,
'pages': 2,
'total': 3,
}
def test_it_gets_users_list_ordered_by_username(app, user_1, user_2, user_3):
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.get(
'/api/users?order_by=username',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['users']) == 3
assert 'sam' in data['data']['users'][0]['username']
assert 'test' in data['data']['users'][1]['username']
assert 'toto' in data['data']['users'][2]['username']
assert data['pagination'] == {
'has_next': False,
'has_prev': False,
'page': 1,
'pages': 1,
'total': 3,
}
def test_it_gets_users_list_ordered_by_username_ascending(
app, user_1, user_2, user_3
):
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.get(
'/api/users?order_by=username&order=asc',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['users']) == 3
assert 'sam' in data['data']['users'][0]['username']
assert 'test' in data['data']['users'][1]['username']
assert 'toto' in data['data']['users'][2]['username']
assert data['pagination'] == {
'has_next': False,
'has_prev': False,
'page': 1,
'pages': 1,
'total': 3,
}
def test_it_gets_users_list_ordered_by_username_descending(
app, user_1, user_2, user_3
):
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.get(
'/api/users?order_by=username&order=desc',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['users']) == 3
assert 'toto' in data['data']['users'][0]['username']
assert 'test' in data['data']['users'][1]['username']
assert 'sam' in data['data']['users'][2]['username']
assert data['pagination'] == {
'has_next': False,
'has_prev': False,
'page': 1,
'pages': 1,
'total': 3,
}
def test_it_gets_users_list_ordered_by_creation_date(
app, user_2, user_3, user_1_admin
):
user_2.created_at = datetime.utcnow() - timedelta(days=1)
user_3.created_at = datetime.utcnow() - timedelta(hours=1)
user_1_admin.created_at = datetime.utcnow()
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.get(
'/api/users?order_by=created_at',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['users']) == 3
assert 'toto' in data['data']['users'][0]['username']
assert 'sam' in data['data']['users'][1]['username']
assert 'admin' in data['data']['users'][2]['username']
assert data['pagination'] == {
'has_next': False,
'has_prev': False,
'page': 1,
'pages': 1,
'total': 3,
}
def test_it_gets_users_list_ordered_by_creation_date_ascending(
app, user_2, user_3, user_1_admin
):
user_2.created_at = datetime.utcnow() - timedelta(days=1)
user_3.created_at = datetime.utcnow() - timedelta(hours=1)
user_1_admin.created_at = datetime.utcnow()
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.get(
'/api/users?order_by=created_at&order=asc',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['users']) == 3
assert 'toto' in data['data']['users'][0]['username']
assert 'sam' in data['data']['users'][1]['username']
assert 'admin' in data['data']['users'][2]['username']
assert data['pagination'] == {
'has_next': False,
'has_prev': False,
'page': 1,
'pages': 1,
'total': 3,
}
def test_it_gets_users_list_ordered_by_creation_date_descending(
app, user_2, user_3, user_1_admin
):
user_2.created_at = datetime.utcnow() - timedelta(days=1)
user_3.created_at = datetime.utcnow() - timedelta(hours=1)
user_1_admin.created_at = datetime.utcnow()
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.get(
'/api/users?order_by=created_at&order=desc',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['users']) == 3
assert 'admin' in data['data']['users'][0]['username']
assert 'sam' in data['data']['users'][1]['username']
assert 'toto' in data['data']['users'][2]['username']
assert data['pagination'] == {
'has_next': False,
'has_prev': False,
'page': 1,
'pages': 1,
'total': 3,
}
def test_it_gets_users_list_ordered_by_admin_rights(
app, user_2, user_1_admin, user_3
):
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.get(
'/api/users?order_by=admin',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['users']) == 3
assert 'toto' in data['data']['users'][0]['username']
assert 'sam' in data['data']['users'][1]['username']
assert 'admin' in data['data']['users'][2]['username']
assert data['pagination'] == {
'has_next': False,
'has_prev': False,
'page': 1,
'pages': 1,
'total': 3,
}
def test_it_gets_users_list_ordered_by_admin_rights_ascending(
app, user_2, user_1_admin, user_3
):
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.get(
'/api/users?order_by=admin&order=asc',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['users']) == 3
assert 'toto' in data['data']['users'][0]['username']
assert 'sam' in data['data']['users'][1]['username']
assert 'admin' in data['data']['users'][2]['username']
assert data['pagination'] == {
'has_next': False,
'has_prev': False,
'page': 1,
'pages': 1,
'total': 3,
}
def test_it_gets_users_list_ordered_by_admin_rights_descending(
app, user_2, user_3, user_1_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.get(
'/api/users?order_by=admin&order=desc',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['users']) == 3
assert 'admin' in data['data']['users'][0]['username']
assert 'toto' in data['data']['users'][1]['username']
assert 'sam' in data['data']['users'][2]['username']
assert data['pagination'] == {
'has_next': False,
'has_prev': False,
'page': 1,
'pages': 1,
'total': 3,
}
def test_it_gets_users_list_ordered_by_activities_count(
app, user_1, user_2, user_3, sport_1_cycling, activity_cycling_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.get(
'/api/users?order_by=activities_count',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['users']) == 3
assert 'test' in data['data']['users'][0]['username']
assert 0 == data['data']['users'][0]['nb_activities']
assert 'sam' in data['data']['users'][1]['username']
assert 0 == data['data']['users'][1]['nb_activities']
assert 'toto' in data['data']['users'][2]['username']
assert 1 == data['data']['users'][2]['nb_activities']
assert data['pagination'] == {
'has_next': False,
'has_prev': False,
'page': 1,
'pages': 1,
'total': 3,
}
def test_it_gets_users_list_ordered_by_activities_count_ascending(
app, user_1, user_2, user_3, sport_1_cycling, activity_cycling_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.get(
'/api/users?order_by=activities_count&order=asc',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['users']) == 3
assert 'test' in data['data']['users'][0]['username']
assert 0 == data['data']['users'][0]['nb_activities']
assert 'sam' in data['data']['users'][1]['username']
assert 0 == data['data']['users'][1]['nb_activities']
assert 'toto' in data['data']['users'][2]['username']
assert 1 == data['data']['users'][2]['nb_activities']
assert data['pagination'] == {
'has_next': False,
'has_prev': False,
'page': 1,
'pages': 1,
'total': 3,
}
def test_it_gets_users_list_ordered_by_activities_count_descending(
app, user_1, user_2, user_3, sport_1_cycling, activity_cycling_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.get(
'/api/users?order_by=activities_count&order=desc',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['users']) == 3
assert 'toto' in data['data']['users'][0]['username']
assert 1 == data['data']['users'][0]['nb_activities']
assert 'test' in data['data']['users'][1]['username']
assert 0 == data['data']['users'][1]['nb_activities']
assert 'sam' in data['data']['users'][2]['username']
assert 0 == data['data']['users'][2]['nb_activities']
assert data['pagination'] == {
'has_next': False,
'has_prev': False,
'page': 1,
'pages': 1,
'total': 3,
}
def test_it_gets_users_list_filtering_on_username(app, user_1, user_2, user_3):
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.get(
'/api/users?q=toto',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['users']) == 1
assert 'toto' in data['data']['users'][0]['username']
assert data['pagination'] == {
'has_next': False,
'has_prev': False,
'page': 1,
'pages': 1,
'total': 1,
}
def test_it_returns_empty_users_list_filtering_on_username(
app, user_1, user_2, user_3
):
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.get(
'/api/users?q=not_existing',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['users']) == 0
assert data['pagination'] == {
'has_next': False,
'has_prev': False,
'page': 1,
'pages': 0,
'total': 0,
}
def test_it_users_list_with_complex_query(app, user_1, user_2, user_3):
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.get(
'/api/users?order_by=username&order=desc&page=2&per_page=2',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['users']) == 1
assert 'sam' in data['data']['users'][0]['username']
assert data['pagination'] == {
'has_next': False,
'has_prev': True,
'page': 2,
'pages': 2,
'total': 3,
}
def test_encode_auth_token(app, user_1): def test_encode_auth_token(app, user_1):

View File

@ -4,6 +4,8 @@ import jwt
from fittrackee_api import bcrypt, db from fittrackee_api import bcrypt, db
from flask import current_app from flask import current_app
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.sql.expression import select
from ..activities.models import Activity from ..activities.models import Activity
@ -88,13 +90,22 @@ class User(db.Model):
except jwt.InvalidTokenError: except jwt.InvalidTokenError:
return 'Invalid token. Please log in again.' return 'Invalid token. Please log in again.'
@hybrid_property
def activities_count(self):
return Activity.query.filter(Activity.user_id == self.id).count()
@activities_count.expression
def activities_count(self):
return (
select([func.count(Activity.id)])
.where(Activity.user_id == self.id)
.label("activities_count")
)
def serialize(self): def serialize(self):
nb_activity = Activity.query.filter(
Activity.user_id == self.id
).count()
sports = [] sports = []
total = (None, None) total = (None, None)
if nb_activity > 0: if self.activities_count > 0:
sports = ( sports = (
db.session.query(Activity.sport_id) db.session.query(Activity.sport_id)
.filter(Activity.user_id == self.id) .filter(Activity.user_id == self.id)
@ -123,7 +134,7 @@ class User(db.Model):
'timezone': self.timezone, 'timezone': self.timezone,
'weekm': self.weekm, 'weekm': self.weekm,
'language': self.language, 'language': self.language,
'nb_activities': nb_activity, 'nb_activities': self.activities_count,
'nb_sports': len(sports), 'nb_sports': len(sports),
'sports_list': [ 'sports_list': [
sport for sportslist in sports for sport in sportslist sport for sportslist in sports for sport in sportslist

View File

@ -11,6 +11,8 @@ from .utils import authenticate, authenticate_as_admin
users_blueprint = Blueprint('users', __name__) users_blueprint = Blueprint('users', __name__)
USER_PER_PAGE = 10
@users_blueprint.route('/users', methods=['GET']) @users_blueprint.route('/users', methods=['GET'])
@authenticate @authenticate
@ -20,9 +22,18 @@ def get_users(auth_user_id):
**Example request**: **Example request**:
- without parameters
.. sourcecode:: http .. sourcecode:: http
GET /api/users HTTP/1.1 GET /api/users/ HTTP/1.1
Content-Type: application/json
- with some query parameters
.. sourcecode:: http
GET /api/users?order_by=activities_count&par_page=5 HTTP/1.1
Content-Type: application/json Content-Type: application/json
**Example response**: **Example response**:
@ -84,6 +95,13 @@ def get_users(auth_user_id):
:param integer auth_user_id: authenticate user id (from JSON Web Token) :param integer auth_user_id: authenticate user id (from JSON Web Token)
: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``,
``activities_count``, ``admin``)
:query string order: sorting order (default: ``asc``)
:reqheader Authorization: OAuth 2.0 Bearer Token :reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: success :statuscode 200: success
@ -93,10 +111,61 @@ def get_users(auth_user_id):
- Invalid token. Please log in again. - Invalid token. Please log in again.
""" """
users = User.query.all() params = request.args.copy()
page = 1 if 'page' not in params.keys() else int(params.get('page'))
per_page = (
int(params.get('per_page'))
if params.get('per_page')
else 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.activities_count.asc()
if order_by == 'activities_count' and order == 'asc'
else True,
User.activities_count.desc()
if order_by == 'activities_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
response_object = { response_object = {
'status': 'success', 'status': 'success',
'data': {'users': [user.serialize() for user in users]}, 'data': {'users': [user.serialize() 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,
},
} }
return jsonify(response_object), 200 return jsonify(response_object), 200
@ -227,6 +296,7 @@ def get_picture(user_name):
def update_user(auth_user_id, user_name): def update_user(auth_user_id, user_name):
""" """
Update user to add admin rights Update user to add admin rights
Only user with admin rights can modify another user Only user with admin rights can modify another user
**Example request**: **Example request**:
@ -321,12 +391,14 @@ def update_user(auth_user_id, user_name):
@users_blueprint.route('/users/<user_name>', methods=['DELETE']) @users_blueprint.route('/users/<user_name>', methods=['DELETE'])
@authenticate @authenticate
def delete_activity(auth_user_id, user_name): def delete_user(auth_user_id, user_name):
""" """
Delete a user account 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 A user can only delete his own account
one admin
An admin can delete all accounts except his account if he's the only
one admin
**Example request**: **Example request**:

View File

@ -8,6 +8,12 @@ export const setData = (target, data) => ({
data, data,
target, target,
}) })
export const setPaginatedData = (target, data, pagination) => ({
type: 'SET_PAGINATED_DATA',
data,
pagination,
target,
})
export const setError = message => ({ export const setError = message => ({
type: 'SET_ERROR', type: 'SET_ERROR',
@ -50,6 +56,9 @@ export const getOrUpdateData = (
.then(ret => { .then(ret => {
if (ret.status === 'success') { if (ret.status === 'success') {
if (canDispatch) { if (canDispatch) {
if (target === 'users' && action === 'getData') {
return dispatch(setPaginatedData(target, ret.data, ret.pagination))
}
dispatch(setData(target, ret.data)) dispatch(setData(target, ret.data))
} else if (action === 'updateData' && target === 'sports') { } else if (action === 'updateData' && target === 'sports') {
dispatch(updateSportsData(ret.data.sports[0])) dispatch(updateSportsData(ret.data.sports[0]))

View File

@ -5,17 +5,69 @@ import { Helmet } from 'react-helmet'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import Message from '../Common/Message' import Message from '../Common/Message'
import Pagination from '../Common/Pagination'
import { history } from '../../index' import { history } from '../../index'
import { getOrUpdateData } from '../../actions' import { getOrUpdateData } from '../../actions'
import { apiUrl } from '../../utils' import {
apiUrl,
formatUrl,
sortOrders,
translateValues,
userFilters,
} from '../../utils'
class AdminUsers extends React.Component { class AdminUsers extends React.Component {
constructor(props, context) {
super(props, context)
this.state = {
page: null,
per_page: null,
order_by: 'created_at',
order: 'asc',
}
}
componentDidMount() { componentDidMount() {
this.props.loadUsers() this.initState()
this.props.loadUsers(this.props.location.query)
}
componentDidUpdate(prevProps) {
if (prevProps.location.query !== this.props.location.query) {
this.props.loadUsers(this.props.location.query)
}
}
initState() {
const { query } = this.props.location
this.setState({
page: query.page,
per_page: query.per_page,
order_by: query.order_by ? query.order_by : 'created_at',
order: query.order ? query.order : 'asc',
})
}
updatePage(key, value) {
const query = Object.assign({}, this.state)
query[key] = value
this.setState(query)
const url = formatUrl(this.props.location.pathname, query)
history.push(url)
} }
render() { render() {
const { message, t, updateUser, authUser, users } = this.props const {
authUser,
location,
message,
t,
pagination,
updateUser,
users,
} = this.props
const translatedFilters = translateValues(t, userFilters)
const translatedSortOrders = translateValues(t, sortOrders)
return ( return (
<div> <div>
<Helmet> <Helmet>
@ -24,89 +76,143 @@ class AdminUsers extends React.Component {
{message && <Message message={message} t={t} />} {message && <Message message={message} t={t} />}
<div className="container"> <div className="container">
<div className="row"> <div className="row">
<div className="col card"> <div className="col">
<div className="card-body"> <div className="card">
<table className="table table-borderless"> <div className="card-header">{t('administration:Users')}</div>
<thead> <div className="card-body">
<tr> <div className="row user-filters">
<th>#</th> <div className="col-lg-4 col-md-6 col-sm-12">
<th>{t('user:Username')}</th> <label htmlFor="order_by">
<th>{t('user:Email')}</th> {t('common:Sort by')}:{' '}
<th>{t('user:Registration Date')}</th> <select
<th>{t('activities:Activities')}</th> id="order_by"
<th>{t('user:Admin')}</th> name="order_by"
<th>{t('administration:Actions')}</th> value={this.state.order_by}
</tr> onChange={e =>
</thead> this.updatePage('order_by', e.target.value)
<tbody> }
{users.map(user => ( >
<tr key={user.username}> {translatedFilters.map(filter => (
<td> <option key={filter.key} value={filter.key}>
{user.picture === true && ( {filter.label}
<img </option>
alt="Avatar" ))}
src={`${apiUrl}users/${ </select>{' '}
user.username </label>
}/picture?${Date.now()}`} </div>
className="img-fluid App-nav-profile-img" <div className="col-lg-4 col-md-6 col-sm-12">
/> <label htmlFor="sort">
)} {t('common:Sort')}:{' '}
</td> <select
<td> id="sort"
<Link to={`/users/${user.username}`}> name="sort"
{user.username} value={this.state.order}
</Link> onChange={e =>
</td> this.updatePage('order', e.target.value)
<td>{user.email}</td> }
<td> >
{format( {translatedSortOrders.map(sort => (
new Date(user.created_at), <option key={sort.key} value={sort.key}>
'dd/MM/yyyy HH:mm' {sort.label}
)} </option>
</td> ))}
<td>{user.nb_activities}</td> </select>{' '}
<td> </label>
{user.admin ? ( </div>
<i </div>
className="fa fa-check-square-o custom-fa" <table className="table table-borderless">
aria-hidden="true" <thead>
data-toggle="tooltip" <tr>
/> <th>#</th>
) : ( <th>{t('user:Username')}</th>
<i <th>{t('user:Email')}</th>
className="fa fa-square-o custom-fa" <th>{t('user:Registration Date')}</th>
aria-hidden="true" <th>{t('activities:Activities')}</th>
data-toggle="tooltip" <th>{t('user:Admin')}</th>
/> <th>{t('administration:Actions')}</th>
)}
</td>
<td>
<input
type="submit"
className={`btn btn-${
user.admin ? 'dark' : 'primary'
} btn-sm`}
disabled={user.username === authUser.username}
value={
user.admin
? t('administration:Remove admin rights')
: t('administration:Add admin rights')
}
onClick={() =>
updateUser(user.username, !user.admin)
}
/>
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {users.map(user => (
<input <tr key={user.username}>
type="submit" <td>
className="btn btn-secondary" {user.picture === true ? (
onClick={() => history.push('/admin/')} <img
value={t('common:Back')} alt="Avatar"
/> src={`${apiUrl}users/${
user.username
}/picture?${Date.now()}`}
className="img-fluid App-nav-profile-img"
/>
) : (
<i
className="fa fa-user-circle-o fa-2x no-picture"
aria-hidden="true"
/>
)}
</td>
<td>
<Link to={`/users/${user.username}`}>
{user.username}
</Link>
</td>
<td>{user.email}</td>
<td>
{format(
new Date(user.created_at),
'dd/MM/yyyy HH:mm'
)}
</td>
<td>{user.nb_activities}</td>
<td>
{user.admin ? (
<i
className="fa fa-check-square-o custom-fa"
aria-hidden="true"
data-toggle="tooltip"
/>
) : (
<i
className="fa fa-square-o custom-fa"
aria-hidden="true"
data-toggle="tooltip"
/>
)}
</td>
<td>
<input
type="submit"
className={`btn btn-${
user.admin ? 'dark' : 'primary'
} btn-sm`}
disabled={user.username === authUser.username}
value={
user.admin
? t('administration:Remove admin rights')
: t('administration:Add admin rights')
}
onClick={() =>
updateUser(user.username, !user.admin)
}
/>
</td>
</tr>
))}
</tbody>
</table>
<Pagination
pagination={pagination}
pathname={location.pathname}
query={this.state}
t={t}
/>
<input
type="submit"
className="btn btn-secondary"
onClick={() => history.push('/admin/')}
value={t('common:Back')}
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -118,13 +224,15 @@ class AdminUsers extends React.Component {
export default connect( export default connect(
state => ({ state => ({
message: state.message,
authUser: state.user, authUser: state.user,
location: state.router.location,
message: state.message,
pagination: state.users.pagination,
users: state.users.data, users: state.users.data,
}), }),
dispatch => ({ dispatch => ({
loadUsers: () => { loadUsers: query => {
dispatch(getOrUpdateData('getData', 'users')) dispatch(getOrUpdateData('getData', 'users', query))
}, },
updateUser: (userName, isAdmin) => { updateUser: (userName, isAdmin) => {
const data = { username: userName, admin: isAdmin } const data = { username: userName, admin: isAdmin }

View File

@ -36,8 +36,8 @@ body {
} }
.App-nav-profile-img { .App-nav-profile-img {
max-width: 35px; max-width: 32px;
max-height: 35px; max-height: 32px;
border-radius: 50%; border-radius: 50%;
} }
@ -427,6 +427,10 @@ label {
padding-right: 2px; padding-right: 2px;
} }
.no-picture {
color: #40578a;
}
.page-title { .page-title {
font-size: 2em; font-size: 2em;
margin: 1em; margin: 1em;
@ -518,6 +522,11 @@ label {
color: black; color: black;
} }
.user-filters {
font-size: 0.9em;
margin-bottom: 5px;
}
.weather-img { .weather-img {
max-width: 35px; max-width: 35px;
max-height: 35px; max-height: 35px;

View File

@ -0,0 +1,72 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { formatUrl, rangePagination } from '../../utils'
export default class Pagination extends React.PureComponent {
getUrl(value) {
const { query, pathname } = this.props
const newQuery = Object.assign({}, query)
let page = query.page ? +query.page : 1
switch (value) {
case 'prev':
page -= 1
break
case 'next':
page += 1
break
default:
page = +value
}
newQuery.page = page
return formatUrl(pathname, newQuery)
}
render() {
const { pagination, t } = this.props
return (
<>
{pagination && Object.keys(pagination).length > 0 && (
<nav aria-label="Page navigation example">
<ul className="pagination justify-content-center">
<li
className={`page-item ${pagination.has_prev ? '' : 'disabled'}`}
>
<Link
className="page-link"
to={this.getUrl('prev')}
aria-disabled={!pagination.has_prev}
>
{t('common:Previous')}
</Link>
</li>
{rangePagination(pagination.pages).map(page => (
<li
key={page}
className={`page-item ${
page === pagination.page ? 'active' : ''
}`}
>
<Link className="page-link" to={this.getUrl(page)}>
{page}
</Link>
</li>
))}
<li
className={`page-item ${pagination.has_next ? '' : 'disabled'}`}
>
<Link
className="page-link"
to={this.getUrl('next')}
aria-disabled={!pagination.has_next}
>
{t('common:Next')}
</Link>
</li>
</ul>
</nav>
)}
</>
)
}
}

View File

@ -1,14 +1,8 @@
import { createApiRequest } from '../utils' import { createApiRequest, formatUrl } from '../utils'
export default class FitTrackeeApi { export default class FitTrackeeApi {
static getData(target, data = {}) { static getData(target, data = {}) {
let url = target const url = formatUrl(target, data)
if (data.id || (target === 'users' && data.username)) {
url = `${url}/${data.username ? data.username : data.id}`
} else if (Object.keys(data).length > 0) {
url += '?'
Object.keys(data).map(key => (url += `&${key}=${data[key]}`))
}
const params = { const params = {
url: url, url: url,
method: 'GET', method: 'GET',

View File

@ -1,27 +1,37 @@
{ {
"activities count": "activities count",
"Add workout": "Add workout", "Add workout": "Add workout",
"admin rights": "admin rights",
"ascending": "ascending",
"Back": "Back", "Back": "Back",
"Cancel": "Cancel", "Cancel": "Cancel",
"Confirmation": "Confirmation", "Confirmation": "Confirmation",
"Dashboard": "Dashboard", "Dashboard": "Dashboard",
"descending": "descending",
"Edit": "Edit", "Edit": "Edit",
"day": "day", "day": "day",
"days": "days", "days": "days",
"Login": "Login", "Login": "Login",
"Logout": "Logout", "Logout": "Logout",
"Next": "Next",
"No": "No", "No": "No",
"no": "no", "no": "no",
"No records.": "No records.", "No records.": "No records.",
"No workouts.": "No workouts.", "No workouts.": "No workouts.",
"Page not found": "Page not found", "Page not found": "Page not found",
"Previous": "Prev",
"Register": "Register", "Register": "Register",
"Statistics": "Statistics", "registration date": "registration date",
"Sort": "Sort",
"Sort by": "Sort by",
"Sport": "Sport", "Sport": "Sport",
"sport": "sport", "sport": "sport",
"Sports": "Sports", "Sports": "Sports",
"sports": "sports", "sports": "sports",
"Statistics": "Statistics",
"Submit": "Submit", "Submit": "Submit",
"to": "to", "to": "to",
"user name": "user name",
"Workout": "Workout", "Workout": "Workout",
"Workouts": "Workouts", "Workouts": "Workouts",
"workout": "workout", "workout": "workout",

View File

@ -1,27 +1,37 @@
{ {
"activities count": "nombre d'activités",
"Add workout": "Ajouter une activité", "Add workout": "Ajouter une activité",
"admin rights": "droits d'admin",
"ascending": "ascendant",
"Back": "Revenir à la page précédente", "Back": "Revenir à la page précédente",
"Cancel": "Annuler", "Cancel": "Annuler",
"Confirmation": "Confirmation", "Confirmation": "Confirmation",
"Dashboard": "Tableau de Bord", "Dashboard": "Tableau de Bord",
"descending": "descendant",
"Edit": "Modifier", "Edit": "Modifier",
"day": "jour", "day": "jour",
"days": "jours", "days": "jours",
"Login": "Se connecter", "Login": "Se connecter",
"Logout": "Se déconnecter", "Logout": "Se déconnecter",
"Next": "Page suivante",
"No": "Non", "No": "Non",
"no": "non", "no": "non",
"No records.": "Pas de records.", "No records.": "Pas de records.",
"No workouts.": "Pas d'activités.", "No workouts.": "Pas d'activités.",
"Page not found": "Page introuvable", "Page not found": "Page introuvable",
"Previous": "Page précédente",
"Register": "S'inscrire", "Register": "S'inscrire",
"Statistics": "Statistiques", "registration date": "date d'inscription",
"Sort": "Tri",
"Sort by": "Trier par",
"Sport": "Sport", "Sport": "Sport",
"sport": "sport", "sport": "sport",
"Sports": "Sports", "Sports": "Sports",
"sports": "sports", "sports": "sports",
"Statistics": "Statistiques",
"Submit": "Valider", "Submit": "Valider",
"to": "à", "to": "à",
"user name": "utilisateur",
"Workout": "Activité", "Workout": "Activité",
"Workouts": "Activités", "Workouts": "Activités",
"workout": "activité", "workout": "activité",

View File

@ -13,6 +13,13 @@ const handleDataAndError = (state, type, action) => {
data: action.data[action.target], data: action.data[action.target],
} }
} }
if (action.type === 'SET_PAGINATED_DATA') {
return {
...state,
data: action.data[action.target],
pagination: action.pagination,
}
}
return state return state
} }

View File

@ -24,6 +24,18 @@ export const thunderforestApiKey = `${
process.env.REACT_APP_THUNDERFOREST_API_KEY process.env.REACT_APP_THUNDERFOREST_API_KEY
}` }`
export const userFilters = [
{ key: 'activities_count', label: 'activities count' },
{ key: 'admin', label: 'admin rights' },
{ key: 'created_at', label: 'registration date' },
{ key: 'username', label: 'user name' },
]
export const sortOrders = [
{ key: 'asc', label: 'ascending' },
{ key: 'desc', label: 'descending' },
]
export const isLoggedIn = () => !!window.localStorage.authToken export const isLoggedIn = () => !!window.localStorage.authToken
export const generateIds = arr => { export const generateIds = arr => {
@ -81,3 +93,35 @@ export const getDateWithTZ = (date, tz) => {
export const capitalize = target => export const capitalize = target =>
target.charAt(0).toUpperCase() + target.slice(1) target.charAt(0).toUpperCase() + target.slice(1)
export const rangePagination = pages =>
Array.from({ length: pages }, (_, i) => i + 1)
const sortValues = (a, b) => {
const valueALabel = a.label.toLowerCase()
const valueBLabel = b.label.toLowerCase()
return valueALabel > valueBLabel ? 1 : valueALabel < valueBLabel ? -1 : 0
}
export const translateValues = (t, values, key = 'common') =>
values
.map(value => ({
...value,
label: t(`${key}:${value.label}`),
}))
.sort(sortValues)
export const formatUrl = (pathname, query) => {
let url = pathname
if (query.id || (pathname === 'users' && query.username)) {
url = `${url}/${query.username ? query.username : query.id}`
} else if (Object.keys(query).length > 0) {
url += '?'
Object.keys(query)
.filter(key => query[key])
.map(
(key, index) => (url += `${index === 0 ? '' : '&'}${key}=${query[key]}`)
)
}
return url
}