API - add user sport preferences
This commit is contained in:
@ -0,0 +1,46 @@
|
||||
"""add sport preferences
|
||||
|
||||
Revision ID: 080acc8ee956
|
||||
Revises: 9842464bb885
|
||||
Create Date: 2021-11-12 10:20:23.786727
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '080acc8ee956'
|
||||
down_revision = '9842464bb885'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'users_sports_preferences',
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('sport_id', sa.Integer(), nullable=False),
|
||||
sa.Column('color', sa.String(length=50), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column(
|
||||
'stopped_speed_threshold',
|
||||
sa.Float(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
['sport_id'],
|
||||
['sports.id'],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
['user_id'],
|
||||
['users.id'],
|
||||
),
|
||||
sa.PrimaryKeyConstraint('user_id', 'sport_id'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('users_sports_preferences')
|
||||
# ### end Alembic commands ###
|
17
fittrackee/tests/fixtures/fixtures_users.py
vendored
17
fittrackee/tests/fixtures/fixtures_users.py
vendored
@ -3,7 +3,8 @@ import datetime
|
||||
import pytest
|
||||
|
||||
from fittrackee import db
|
||||
from fittrackee.users.models import User
|
||||
from fittrackee.users.models import User, UserSportPreference
|
||||
from fittrackee.workouts.models import Sport
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@ -81,3 +82,17 @@ def user_3() -> User:
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def user_sport_1_preference(
|
||||
user_1: User, sport_1_cycling: Sport
|
||||
) -> UserSportPreference:
|
||||
user_sport = UserSportPreference(
|
||||
user_id=user_1.id,
|
||||
sport_id=sport_1_cycling.id,
|
||||
stopped_speed_threshold=sport_1_cycling.stopped_speed_threshold,
|
||||
)
|
||||
db.session.add(user_sport)
|
||||
db.session.commit()
|
||||
return user_sport
|
||||
|
@ -830,6 +830,186 @@ class TestUserPreferencesUpdate(ApiTestCaseMixin):
|
||||
assert 'error' in data['status']
|
||||
|
||||
|
||||
class TestUserSportPreferencesUpdate(ApiTestCaseMixin):
|
||||
def test_it_returns_error_if_payload_is_empty(
|
||||
self, app: Flask, user_1: User
|
||||
) -> None:
|
||||
client, auth_token = self.get_test_client_and_auth_token(app)
|
||||
|
||||
response = client.post(
|
||||
'/api/auth/profile/edit/sports',
|
||||
content_type='application/json',
|
||||
data=json.dumps(dict()),
|
||||
headers=dict(Authorization=f'Bearer {auth_token}'),
|
||||
)
|
||||
|
||||
data = json.loads(response.data.decode())
|
||||
assert response.status_code == 400
|
||||
assert 'invalid payload' in data['message']
|
||||
assert 'error' in data['status']
|
||||
|
||||
def test_it_returns_error_if_sport_id_is_missing(
|
||||
self, app: Flask, user_1: User
|
||||
) -> None:
|
||||
client, auth_token = self.get_test_client_and_auth_token(app)
|
||||
|
||||
response = client.post(
|
||||
'/api/auth/profile/edit/sports',
|
||||
content_type='application/json',
|
||||
data=json.dumps(dict(is_active=True)),
|
||||
headers=dict(Authorization=f'Bearer {auth_token}'),
|
||||
)
|
||||
|
||||
data = json.loads(response.data.decode())
|
||||
assert data['status'] == 'error'
|
||||
assert data['message'] == 'invalid payload'
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_it_returns_error_if_sport_not_found(
|
||||
self, app: Flask, user_1: User
|
||||
) -> None:
|
||||
client, auth_token = self.get_test_client_and_auth_token(app)
|
||||
|
||||
response = client.post(
|
||||
'/api/auth/profile/edit/sports',
|
||||
content_type='application/json',
|
||||
data=json.dumps(dict(sport_id=1, is_active=True)),
|
||||
headers=dict(Authorization=f'Bearer {auth_token}'),
|
||||
)
|
||||
|
||||
data = json.loads(response.data.decode())
|
||||
assert response.status_code == 404
|
||||
assert 'not found' in data['status']
|
||||
|
||||
def test_it_returns_error_if_payload_contains_only_sport_id(
|
||||
self, app: Flask, user_1: User, sport_1_cycling: Sport
|
||||
) -> None:
|
||||
client, auth_token = self.get_test_client_and_auth_token(app)
|
||||
|
||||
response = client.post(
|
||||
'/api/auth/profile/edit/sports',
|
||||
content_type='application/json',
|
||||
data=json.dumps(dict(sport_id=1)),
|
||||
headers=dict(Authorization=f'Bearer {auth_token}'),
|
||||
)
|
||||
|
||||
data = json.loads(response.data.decode())
|
||||
assert data['status'] == 'error'
|
||||
assert data['message'] == 'invalid payload'
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_it_returns_error_if_color_is_invalid(
|
||||
self, app: Flask, user_1: User, sport_1_cycling: Sport
|
||||
) -> None:
|
||||
client, auth_token = self.get_test_client_and_auth_token(app)
|
||||
|
||||
response = client.post(
|
||||
'/api/auth/profile/edit/sports',
|
||||
content_type='application/json',
|
||||
data=json.dumps(
|
||||
dict(
|
||||
sport_id=sport_1_cycling.id,
|
||||
color='invalid',
|
||||
)
|
||||
),
|
||||
headers=dict(Authorization=f'Bearer {auth_token}'),
|
||||
)
|
||||
|
||||
data = json.loads(response.data.decode())
|
||||
assert data['status'] == 'error'
|
||||
assert data['message'] == 'invalid hexadecimal color'
|
||||
assert response.status_code == 400
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'input_color',
|
||||
['#000000', '#FFF'],
|
||||
)
|
||||
def test_it_updates_sport_color_for_auth_user(
|
||||
self,
|
||||
app: Flask,
|
||||
user_1: User,
|
||||
sport_2_running: Sport,
|
||||
input_color: str,
|
||||
) -> None:
|
||||
client, auth_token = self.get_test_client_and_auth_token(app)
|
||||
|
||||
response = client.post(
|
||||
'/api/auth/profile/edit/sports',
|
||||
content_type='application/json',
|
||||
data=json.dumps(
|
||||
dict(
|
||||
sport_id=sport_2_running.id,
|
||||
color=input_color,
|
||||
)
|
||||
),
|
||||
headers=dict(Authorization=f'Bearer {auth_token}'),
|
||||
)
|
||||
|
||||
data = json.loads(response.data.decode())
|
||||
assert data['status'] == 'success'
|
||||
assert data['message'] == 'user sport preferences updated'
|
||||
assert response.status_code == 200
|
||||
assert data['data']['user_id'] == user_1.id
|
||||
assert data['data']['sport_id'] == sport_2_running.id
|
||||
assert data['data']['color'] == input_color
|
||||
assert data['data']['is_active'] is True
|
||||
assert data['data']['stopped_speed_threshold'] == 0.1
|
||||
|
||||
def test_it_disables_sport_for_auth_user(
|
||||
self, app: Flask, user_1: User, sport_1_cycling: Sport
|
||||
) -> None:
|
||||
client, auth_token = self.get_test_client_and_auth_token(app)
|
||||
|
||||
response = client.post(
|
||||
'/api/auth/profile/edit/sports',
|
||||
content_type='application/json',
|
||||
data=json.dumps(
|
||||
dict(
|
||||
sport_id=sport_1_cycling.id,
|
||||
is_active=False,
|
||||
)
|
||||
),
|
||||
headers=dict(Authorization=f'Bearer {auth_token}'),
|
||||
)
|
||||
|
||||
data = json.loads(response.data.decode())
|
||||
assert data['status'] == 'success'
|
||||
assert data['message'] == 'user sport preferences updated'
|
||||
assert response.status_code == 200
|
||||
assert data['data']['user_id'] == user_1.id
|
||||
assert data['data']['sport_id'] == sport_1_cycling.id
|
||||
assert data['data']['color'] is None
|
||||
assert data['data']['is_active'] is False
|
||||
assert data['data']['stopped_speed_threshold'] == 1
|
||||
|
||||
def test_it_updates_stopped_speed_threshold_for_auth_user(
|
||||
self, app: Flask, user_1: User, sport_1_cycling: Sport
|
||||
) -> None:
|
||||
client, auth_token = self.get_test_client_and_auth_token(app)
|
||||
|
||||
response = client.post(
|
||||
'/api/auth/profile/edit/sports',
|
||||
content_type='application/json',
|
||||
data=json.dumps(
|
||||
dict(
|
||||
sport_id=sport_1_cycling.id,
|
||||
stopped_speed_threshold=0.5,
|
||||
)
|
||||
),
|
||||
headers=dict(Authorization=f'Bearer {auth_token}'),
|
||||
)
|
||||
|
||||
data = json.loads(response.data.decode())
|
||||
assert data['status'] == 'success'
|
||||
assert data['message'] == 'user sport preferences updated'
|
||||
assert response.status_code == 200
|
||||
assert data['data']['user_id'] == user_1.id
|
||||
assert data['data']['sport_id'] == sport_1_cycling.id
|
||||
assert data['data']['color'] is None
|
||||
assert data['data']['is_active']
|
||||
assert data['data']['stopped_speed_threshold'] == 0.5
|
||||
|
||||
|
||||
class TestUserPicture(ApiTestCaseMixin):
|
||||
def test_it_updates_user_picture(self, app: Flask, user_1: User) -> None:
|
||||
client, auth_token = self.get_test_client_and_auth_token(app)
|
||||
|
@ -1,6 +1,6 @@
|
||||
from flask import Flask
|
||||
|
||||
from fittrackee.users.models import User
|
||||
from fittrackee.users.models import User, UserSportPreference
|
||||
from fittrackee.workouts.models import Sport, Workout
|
||||
|
||||
|
||||
@ -59,3 +59,19 @@ class TestUserModel:
|
||||
== workout_cycling_user_1.short_id
|
||||
)
|
||||
assert serialized_user['records'][0]['workout_date']
|
||||
|
||||
|
||||
class TestUserSportModel:
|
||||
def test_user_model(
|
||||
self,
|
||||
app: Flask,
|
||||
user_1: User,
|
||||
sport_1_cycling: Sport,
|
||||
user_sport_1_preference: UserSportPreference,
|
||||
) -> None:
|
||||
serialized_user_sport = user_sport_1_preference.serialize()
|
||||
assert serialized_user_sport['user_id'] == user_1.id
|
||||
assert serialized_user_sport['sport_id'] == sport_1_cycling.id
|
||||
assert serialized_user_sport['color'] is None
|
||||
assert serialized_user_sport['is_active']
|
||||
assert serialized_user_sport['stopped_speed_threshold'] == 1
|
||||
|
@ -1,5 +1,6 @@
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
from typing import Dict, Tuple, Union
|
||||
|
||||
import jwt
|
||||
@ -10,6 +11,7 @@ from werkzeug.utils import secure_filename
|
||||
|
||||
from fittrackee import appLog, bcrypt, db
|
||||
from fittrackee.responses import (
|
||||
DataNotFoundErrorResponse,
|
||||
ForbiddenErrorResponse,
|
||||
HttpResponse,
|
||||
InvalidPayloadErrorResponse,
|
||||
@ -19,15 +21,18 @@ from fittrackee.responses import (
|
||||
)
|
||||
from fittrackee.tasks import reset_password_email
|
||||
from fittrackee.utils import get_readable_duration, verify_extension_and_size
|
||||
from fittrackee.workouts.models import Sport
|
||||
from fittrackee.workouts.utils_files import get_absolute_file_path
|
||||
|
||||
from .decorators import authenticate
|
||||
from .models import User
|
||||
from .models import User, UserSportPreference
|
||||
from .utils import check_passwords, register_controls
|
||||
from .utils_token import decode_user_token
|
||||
|
||||
auth_blueprint = Blueprint('auth', __name__)
|
||||
|
||||
HEX_COLOR_REGEX = regex = "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"
|
||||
|
||||
|
||||
@auth_blueprint.route('/auth/register', methods=['POST'])
|
||||
def register_user() -> Union[Tuple[Dict, int], HttpResponse]:
|
||||
@ -677,6 +682,108 @@ def edit_user_preferences(auth_user_id: int) -> Union[Dict, HttpResponse]:
|
||||
return handle_error_and_return_response(e, db=db)
|
||||
|
||||
|
||||
@auth_blueprint.route('/auth/profile/edit/sports', methods=['POST'])
|
||||
@authenticate
|
||||
def edit_user_sport_preferences(
|
||||
auth_user_id: int,
|
||||
) -> Union[Dict, HttpResponse]:
|
||||
"""
|
||||
edit authenticated user sport preferences
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/auth/profile/edit/sports HTTP/1.1
|
||||
Content-Type: application/json
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"color": "#000000",
|
||||
"is_active": true,
|
||||
"sport_id": 1,
|
||||
"stopped_speed_threshold": 1,
|
||||
"user_id": 1
|
||||
},
|
||||
"message": "user sport preferences updated",
|
||||
"status": "success"
|
||||
}
|
||||
|
||||
:<json string color: valid hexadecimal color
|
||||
:<json boolean is_active: is sport available when adding a workout
|
||||
:<json float stopped_speed_threshold: stopped speed threshold used by gpxpy
|
||||
|
||||
:reqheader Authorization: OAuth 2.0 Bearer Token
|
||||
|
||||
:statuscode 200: user preferences updated
|
||||
:statuscode 400:
|
||||
- invalid payload
|
||||
- invalid hexadecimal color
|
||||
:statuscode 401:
|
||||
- provide a valid auth token
|
||||
- signature expired, please log in again
|
||||
- invalid token, please log in again
|
||||
:statuscode 500: error, please try again or contact the administrator
|
||||
|
||||
"""
|
||||
post_data = request.get_json()
|
||||
if (
|
||||
not post_data
|
||||
or 'sport_id' not in post_data
|
||||
or len(post_data.keys()) == 1
|
||||
):
|
||||
return InvalidPayloadErrorResponse()
|
||||
|
||||
sport_id = post_data.get('sport_id')
|
||||
sport = Sport.query.filter_by(id=sport_id).first()
|
||||
if not sport:
|
||||
return DataNotFoundErrorResponse('sports')
|
||||
|
||||
color = post_data.get('color')
|
||||
is_active = post_data.get('is_active')
|
||||
stopped_speed_threshold = post_data.get('stopped_speed_threshold')
|
||||
|
||||
try:
|
||||
user_sport = UserSportPreference.query.filter_by(
|
||||
user_id=auth_user_id,
|
||||
sport_id=sport_id,
|
||||
).first()
|
||||
if not user_sport:
|
||||
user_sport = UserSportPreference(
|
||||
user_id=auth_user_id,
|
||||
sport_id=sport_id,
|
||||
stopped_speed_threshold=sport.stopped_speed_threshold,
|
||||
)
|
||||
db.session.add(user_sport)
|
||||
db.session.flush()
|
||||
if color:
|
||||
if re.match(HEX_COLOR_REGEX, color) is None:
|
||||
return InvalidPayloadErrorResponse('invalid hexadecimal color')
|
||||
user_sport.color = color
|
||||
if is_active is not None:
|
||||
user_sport.is_active = is_active
|
||||
if stopped_speed_threshold:
|
||||
user_sport.stopped_speed_threshold = stopped_speed_threshold
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'user sport preferences updated',
|
||||
'data': user_sport.serialize(),
|
||||
}
|
||||
|
||||
# handler errors
|
||||
except (exc.IntegrityError, exc.OperationalError, ValueError) as e:
|
||||
return handle_error_and_return_response(e, db=db)
|
||||
|
||||
|
||||
@auth_blueprint.route('/auth/picture', methods=['POST'])
|
||||
@authenticate
|
||||
def edit_picture(auth_user_id: int) -> Union[Dict, HttpResponse]:
|
||||
|
@ -40,6 +40,11 @@ class User(BaseModel):
|
||||
'Record', lazy=True, backref=db.backref('user', lazy='joined')
|
||||
)
|
||||
language = db.Column(db.String(50), nullable=True)
|
||||
sport_preferences = db.relationship(
|
||||
'UserSportPreference',
|
||||
lazy=True,
|
||||
backref=db.backref('user', lazy='joined'),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<User {self.username!r}>'
|
||||
@ -143,3 +148,41 @@ class User(BaseModel):
|
||||
'total_distance': float(total[0]),
|
||||
'total_duration': str(total[1]),
|
||||
}
|
||||
|
||||
|
||||
class UserSportPreference(BaseModel):
|
||||
__tablename__ = 'users_sports_preferences'
|
||||
|
||||
user_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id'),
|
||||
primary_key=True,
|
||||
)
|
||||
sport_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('sports.id'),
|
||||
primary_key=True,
|
||||
)
|
||||
color = db.Column(db.String(50), nullable=True)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
stopped_speed_threshold = db.Column(db.Float, default=1.0, nullable=False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_id: int,
|
||||
sport_id: int,
|
||||
stopped_speed_threshold: float,
|
||||
) -> None:
|
||||
self.user_id = user_id
|
||||
self.sport_id = sport_id
|
||||
self.is_active = True
|
||||
self.stopped_speed_threshold = stopped_speed_threshold
|
||||
|
||||
def serialize(self) -> Dict:
|
||||
return {
|
||||
'user_id': self.user_id,
|
||||
'sport_id': self.sport_id,
|
||||
'color': self.color,
|
||||
'is_active': self.is_active,
|
||||
'stopped_speed_threshold': self.stopped_speed_threshold,
|
||||
}
|
||||
|
Reference in New Issue
Block a user