API & Client - add pagination and filter on users lists
This commit is contained in:
parent
958c6711a3
commit
1c13aca2eb
3
.flake8
Normal file
3
.flake8
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[flake8]
|
||||||
|
per-file-ignores =
|
||||||
|
fittrackee_api/fittrackee_api/activities/stats.py:E501
|
@ -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,
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
|
@ -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**:
|
||||||
|
@ -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']
|
||||||
|
@ -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):
|
||||||
|
@ -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
|
||||||
|
@ -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**:
|
||||||
|
|
||||||
|
@ -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]))
|
||||||
|
@ -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 }
|
||||||
|
@ -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;
|
||||||
|
72
fittrackee_client/src/components/Common/Pagination.jsx
Normal file
72
fittrackee_client/src/components/Common/Pagination.jsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -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',
|
||||||
|
@ -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",
|
||||||
|
@ -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é",
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user