API - add typing
This commit is contained in:
@ -1,10 +1,12 @@
|
||||
import datetime
|
||||
import os
|
||||
from typing import Dict, Tuple, Union
|
||||
|
||||
import jwt
|
||||
from fittrackee import appLog, bcrypt, db
|
||||
from fittrackee.responses import (
|
||||
ForbiddenErrorResponse,
|
||||
HttpResponse,
|
||||
InvalidPayloadErrorResponse,
|
||||
PayloadTooLargeErrorResponse,
|
||||
UnauthorizedErrorResponse,
|
||||
@ -32,7 +34,7 @@ auth_blueprint = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@auth_blueprint.route('/auth/register', methods=['POST'])
|
||||
def register_user():
|
||||
def register_user() -> Union[Tuple[Dict, int], HttpResponse]:
|
||||
"""
|
||||
register a user
|
||||
|
||||
@ -144,7 +146,7 @@ def register_user():
|
||||
|
||||
|
||||
@auth_blueprint.route('/auth/login', methods=['POST'])
|
||||
def login_user():
|
||||
def login_user() -> Union[Dict, HttpResponse]:
|
||||
"""
|
||||
user login
|
||||
|
||||
@ -216,7 +218,7 @@ def login_user():
|
||||
|
||||
@auth_blueprint.route('/auth/logout', methods=['GET'])
|
||||
@authenticate
|
||||
def logout_user(auth_user_id):
|
||||
def logout_user(auth_user_id: int) -> Union[Dict, HttpResponse]:
|
||||
"""
|
||||
user logout
|
||||
|
||||
@ -277,7 +279,9 @@ def logout_user(auth_user_id):
|
||||
|
||||
@auth_blueprint.route('/auth/profile', methods=['GET'])
|
||||
@authenticate
|
||||
def get_authenticated_user_profile(auth_user_id):
|
||||
def get_authenticated_user_profile(
|
||||
auth_user_id: int,
|
||||
) -> Union[Dict, HttpResponse]:
|
||||
"""
|
||||
get authenticated user info
|
||||
|
||||
@ -338,7 +342,7 @@ def get_authenticated_user_profile(auth_user_id):
|
||||
|
||||
@auth_blueprint.route('/auth/profile/edit', methods=['POST'])
|
||||
@authenticate
|
||||
def edit_user(auth_user_id):
|
||||
def edit_user(auth_user_id: int) -> Union[Dict, HttpResponse]:
|
||||
"""
|
||||
edit authenticated user
|
||||
|
||||
@ -474,7 +478,7 @@ def edit_user(auth_user_id):
|
||||
|
||||
@auth_blueprint.route('/auth/picture', methods=['POST'])
|
||||
@authenticate
|
||||
def edit_picture(auth_user_id):
|
||||
def edit_picture(auth_user_id: int) -> Union[Dict, HttpResponse]:
|
||||
"""
|
||||
update authenticated user picture
|
||||
|
||||
@ -561,7 +565,7 @@ def edit_picture(auth_user_id):
|
||||
|
||||
@auth_blueprint.route('/auth/picture', methods=['DELETE'])
|
||||
@authenticate
|
||||
def del_picture(auth_user_id):
|
||||
def del_picture(auth_user_id: int) -> Union[Tuple[Dict, int], HttpResponse]:
|
||||
"""
|
||||
delete authenticated user picture
|
||||
|
||||
@ -604,7 +608,7 @@ def del_picture(auth_user_id):
|
||||
|
||||
|
||||
@auth_blueprint.route('/auth/password/reset-request', methods=['POST'])
|
||||
def request_password_reset():
|
||||
def request_password_reset() -> Union[Dict, HttpResponse]:
|
||||
"""
|
||||
handle password reset request
|
||||
|
||||
@ -644,15 +648,15 @@ def request_password_reset():
|
||||
ui_url = current_app.config['UI_URL']
|
||||
email_data = {
|
||||
'expiration_delay': get_readable_duration(
|
||||
current_app.config.get('PASSWORD_TOKEN_EXPIRATION_SECONDS'),
|
||||
current_app.config['PASSWORD_TOKEN_EXPIRATION_SECONDS'],
|
||||
'en' if user.language is None else user.language,
|
||||
),
|
||||
'username': user.username,
|
||||
'password_reset_url': (
|
||||
f'{ui_url}/password-reset?token={password_reset_token}' # noqa
|
||||
),
|
||||
'operating_system': request.user_agent.platform,
|
||||
'browser_name': request.user_agent.browser,
|
||||
'operating_system': request.user_agent.platform, # type: ignore
|
||||
'browser_name': request.user_agent.browser, # type: ignore
|
||||
}
|
||||
user_data = {
|
||||
'language': user.language if user.language else 'en',
|
||||
@ -666,7 +670,7 @@ def request_password_reset():
|
||||
|
||||
|
||||
@auth_blueprint.route('/auth/password/update', methods=['POST'])
|
||||
def update_password():
|
||||
def update_password() -> Union[Dict, HttpResponse]:
|
||||
"""
|
||||
update user password
|
||||
|
||||
|
@ -1,18 +1,22 @@
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional, Union
|
||||
|
||||
import jwt
|
||||
from fittrackee import bcrypt, db
|
||||
from flask import current_app
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.ext.declarative import DeclarativeMeta
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy.sql.expression import select
|
||||
|
||||
from ..activities.models import Activity
|
||||
from .utils_token import decode_user_token, get_user_token
|
||||
|
||||
BaseModel: DeclarativeMeta = db.Model
|
||||
|
||||
class User(db.Model):
|
||||
__tablename__ = "users"
|
||||
|
||||
class User(BaseModel):
|
||||
__tablename__ = 'users'
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
username = db.Column(db.String(20), unique=True, nullable=False)
|
||||
email = db.Column(db.String(120), unique=True, nullable=False)
|
||||
@ -36,12 +40,16 @@ class User(db.Model):
|
||||
)
|
||||
language = db.Column(db.String(50), nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
def __repr__(self) -> str:
|
||||
return f'<User {self.username!r}>'
|
||||
|
||||
def __init__(
|
||||
self, username, email, password, created_at=datetime.utcnow()
|
||||
):
|
||||
self,
|
||||
username: str,
|
||||
email: str,
|
||||
password: str,
|
||||
created_at: Optional[datetime] = datetime.utcnow(),
|
||||
) -> None:
|
||||
self.username = username
|
||||
self.email = email
|
||||
self.password = bcrypt.generate_password_hash(
|
||||
@ -50,31 +58,25 @@ class User(db.Model):
|
||||
self.created_at = created_at
|
||||
|
||||
@staticmethod
|
||||
def encode_auth_token(user_id):
|
||||
def encode_auth_token(user_id: int) -> str:
|
||||
"""
|
||||
Generates the auth token
|
||||
:param user_id: -
|
||||
:return: JWToken
|
||||
"""
|
||||
try:
|
||||
return get_user_token(user_id)
|
||||
except Exception as e:
|
||||
return e
|
||||
return get_user_token(user_id)
|
||||
|
||||
@staticmethod
|
||||
def encode_password_reset_token(user_id):
|
||||
def encode_password_reset_token(user_id: int) -> str:
|
||||
"""
|
||||
Generates the auth token
|
||||
:param user_id: -
|
||||
:return: JWToken
|
||||
"""
|
||||
try:
|
||||
return get_user_token(user_id, password_reset=True)
|
||||
except Exception as e:
|
||||
return e
|
||||
return get_user_token(user_id, password_reset=True)
|
||||
|
||||
@staticmethod
|
||||
def decode_auth_token(auth_token):
|
||||
def decode_auth_token(auth_token: str) -> Union[int, str]:
|
||||
"""
|
||||
Decodes the auth token
|
||||
:param auth_token: -
|
||||
@ -88,21 +90,21 @@ class User(db.Model):
|
||||
return 'Invalid token. Please log in again.'
|
||||
|
||||
@hybrid_property
|
||||
def activities_count(self):
|
||||
def activities_count(self) -> int:
|
||||
return Activity.query.filter(Activity.user_id == self.id).count()
|
||||
|
||||
@activities_count.expression
|
||||
def activities_count(self):
|
||||
@activities_count.expression # type: ignore
|
||||
def activities_count(self) -> int:
|
||||
return (
|
||||
select([func.count(Activity.id)])
|
||||
.where(Activity.user_id == self.id)
|
||||
.label("activities_count")
|
||||
.label('activities_count')
|
||||
)
|
||||
|
||||
def serialize(self):
|
||||
def serialize(self) -> Dict:
|
||||
sports = []
|
||||
total = (None, None)
|
||||
if self.activities_count > 0:
|
||||
total = (0, '0:00:00')
|
||||
if self.activities_count > 0: # type: ignore
|
||||
sports = (
|
||||
db.session.query(Activity.sport_id)
|
||||
.filter(Activity.user_id == self.id)
|
||||
@ -136,6 +138,6 @@ class User(db.Model):
|
||||
'sports_list': [
|
||||
sport for sportslist in sports for sport in sportslist
|
||||
],
|
||||
'total_distance': float(total[0]) if total[0] else 0,
|
||||
'total_duration': str(total[1]) if total[1] else "0:00:00",
|
||||
'total_distance': float(total[0]),
|
||||
'total_duration': str(total[1]),
|
||||
}
|
||||
|
@ -1,9 +1,11 @@
|
||||
import os
|
||||
import shutil
|
||||
from typing import Any, Dict, Tuple, Union
|
||||
|
||||
from fittrackee import db
|
||||
from fittrackee.responses import (
|
||||
ForbiddenErrorResponse,
|
||||
HttpResponse,
|
||||
InvalidPayloadErrorResponse,
|
||||
NotFoundErrorResponse,
|
||||
UserNotFoundErrorResponse,
|
||||
@ -23,7 +25,7 @@ USER_PER_PAGE = 10
|
||||
|
||||
@users_blueprint.route('/users', methods=['GET'])
|
||||
@authenticate
|
||||
def get_users(auth_user_id):
|
||||
def get_users(auth_user_id: int) -> Dict:
|
||||
"""
|
||||
Get all users
|
||||
|
||||
@ -135,10 +137,10 @@ def get_users(auth_user_id):
|
||||
User.username.like('%' + query + '%') if query else True,
|
||||
)
|
||||
.order_by(
|
||||
User.activities_count.asc()
|
||||
User.activities_count.asc() # type: ignore
|
||||
if order_by == 'activities_count' and order == 'asc'
|
||||
else True,
|
||||
User.activities_count.desc()
|
||||
User.activities_count.desc() # type: ignore
|
||||
if order_by == 'activities_count' and order == 'desc'
|
||||
else True,
|
||||
User.username.asc()
|
||||
@ -178,7 +180,9 @@ def get_users(auth_user_id):
|
||||
|
||||
@users_blueprint.route('/users/<user_name>', methods=['GET'])
|
||||
@authenticate
|
||||
def get_single_user(auth_user_id, user_name):
|
||||
def get_single_user(
|
||||
auth_user_id: int, user_name: str
|
||||
) -> Union[Dict, HttpResponse]:
|
||||
"""
|
||||
Get single user details
|
||||
|
||||
@ -251,7 +255,7 @@ def get_single_user(auth_user_id, user_name):
|
||||
|
||||
|
||||
@users_blueprint.route('/users/<user_name>/picture', methods=['GET'])
|
||||
def get_picture(user_name):
|
||||
def get_picture(user_name: str) -> Any:
|
||||
"""get user picture
|
||||
|
||||
**Example request**:
|
||||
@ -290,7 +294,9 @@ def get_picture(user_name):
|
||||
|
||||
@users_blueprint.route('/users/<user_name>', methods=['PATCH'])
|
||||
@authenticate_as_admin
|
||||
def update_user(auth_user_id, user_name):
|
||||
def update_user(
|
||||
auth_user_id: int, user_name: str
|
||||
) -> Union[Dict, HttpResponse]:
|
||||
"""
|
||||
Update user to add admin rights
|
||||
|
||||
@ -377,7 +383,9 @@ def update_user(auth_user_id, user_name):
|
||||
|
||||
@users_blueprint.route('/users/<user_name>', methods=['DELETE'])
|
||||
@authenticate
|
||||
def delete_user(auth_user_id, user_name):
|
||||
def delete_user(
|
||||
auth_user_id: int, user_name: str
|
||||
) -> Union[Tuple[Dict, int], HttpResponse]:
|
||||
"""
|
||||
Delete a user account
|
||||
|
||||
|
@ -1,30 +1,44 @@
|
||||
import re
|
||||
from datetime import timedelta
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Optional, Tuple, Union
|
||||
|
||||
import humanize
|
||||
from fittrackee.responses import (
|
||||
ForbiddenErrorResponse,
|
||||
HttpResponse,
|
||||
InvalidPayloadErrorResponse,
|
||||
PayloadTooLargeErrorResponse,
|
||||
UnauthorizedErrorResponse,
|
||||
)
|
||||
from flask import current_app, request
|
||||
from flask import Request, current_app, request
|
||||
|
||||
from .models import User
|
||||
|
||||
|
||||
def is_admin(user_id):
|
||||
def is_admin(user_id: int) -> bool:
|
||||
"""
|
||||
Return if user has admin rights
|
||||
"""
|
||||
user = User.query.filter_by(id=user_id).first()
|
||||
return user.admin
|
||||
|
||||
|
||||
def is_valid_email(email):
|
||||
def is_valid_email(email: str) -> bool:
|
||||
"""
|
||||
Return if email format is valid
|
||||
"""
|
||||
mail_pattern = r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)"
|
||||
return re.match(mail_pattern, email) is not None
|
||||
|
||||
|
||||
def check_passwords(password, password_conf):
|
||||
def check_passwords(password: str, password_conf: str) -> str:
|
||||
"""
|
||||
Verify if password and password confirmation are the same and have
|
||||
more than 8 characters
|
||||
|
||||
If not, it returns not empty string
|
||||
"""
|
||||
ret = ''
|
||||
if password_conf != password:
|
||||
ret = 'Password and password confirmation don\'t match.\n'
|
||||
@ -33,7 +47,14 @@ def check_passwords(password, password_conf):
|
||||
return ret
|
||||
|
||||
|
||||
def register_controls(username, email, password, password_conf):
|
||||
def register_controls(
|
||||
username: str, email: str, password: str, password_conf: str
|
||||
) -> str:
|
||||
"""
|
||||
Verify if user name, email and passwords are valid
|
||||
|
||||
If not, it returns not empty string
|
||||
"""
|
||||
ret = ''
|
||||
if not 2 < len(username) < 13:
|
||||
ret += 'Username: 3 to 12 characters required.\n'
|
||||
@ -43,7 +64,12 @@ def register_controls(username, email, password, password_conf):
|
||||
return ret
|
||||
|
||||
|
||||
def verify_extension_and_size(file_type, req):
|
||||
def verify_extension_and_size(
|
||||
file_type: str, req: Request
|
||||
) -> Optional[HttpResponse]:
|
||||
"""
|
||||
Return error Response if file is invalid
|
||||
"""
|
||||
if 'file' not in req.files:
|
||||
return InvalidPayloadErrorResponse('No file part.', 'fail')
|
||||
|
||||
@ -66,7 +92,7 @@ def verify_extension_and_size(file_type, req):
|
||||
|
||||
if not (
|
||||
file_extension
|
||||
and file_extension in current_app.config.get(allowed_extensions)
|
||||
and file_extension in current_app.config[allowed_extensions]
|
||||
):
|
||||
return InvalidPayloadErrorResponse(
|
||||
'File extension not allowed.', 'fail'
|
||||
@ -81,7 +107,13 @@ def verify_extension_and_size(file_type, req):
|
||||
return None
|
||||
|
||||
|
||||
def verify_user(current_request, verify_admin):
|
||||
def verify_user(
|
||||
current_request: Request, verify_admin: bool
|
||||
) -> Tuple[Optional[HttpResponse], Optional[int]]:
|
||||
"""
|
||||
Return user id, if the provided token is valid and if user has admin
|
||||
rights if 'verify_admin' is True
|
||||
"""
|
||||
default_message = 'Provide a valid auth token.'
|
||||
auth_header = current_request.headers.get('Authorization')
|
||||
if not auth_header:
|
||||
@ -98,9 +130,11 @@ def verify_user(current_request, verify_admin):
|
||||
return None, resp
|
||||
|
||||
|
||||
def authenticate(f):
|
||||
def authenticate(f: Callable) -> Callable:
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
def decorated_function(
|
||||
*args: Any, **kwargs: Any
|
||||
) -> Union[Callable, HttpResponse]:
|
||||
verify_admin = False
|
||||
response_object, resp = verify_user(request, verify_admin)
|
||||
if response_object:
|
||||
@ -110,9 +144,11 @@ def authenticate(f):
|
||||
return decorated_function
|
||||
|
||||
|
||||
def authenticate_as_admin(f):
|
||||
def authenticate_as_admin(f: Callable) -> Callable:
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
def decorated_function(
|
||||
*args: Any, **kwargs: Any
|
||||
) -> Union[Callable, HttpResponse]:
|
||||
verify_admin = True
|
||||
response_object, resp = verify_user(request, verify_admin)
|
||||
if response_object:
|
||||
@ -122,25 +158,36 @@ def authenticate_as_admin(f):
|
||||
return decorated_function
|
||||
|
||||
|
||||
def can_view_activity(auth_user_id, activity_user_id):
|
||||
def can_view_activity(
|
||||
auth_user_id: int, activity_user_id: int
|
||||
) -> Optional[HttpResponse]:
|
||||
"""
|
||||
Return error response if user has no right to view activity
|
||||
"""
|
||||
if auth_user_id != activity_user_id:
|
||||
return ForbiddenErrorResponse()
|
||||
return None
|
||||
|
||||
|
||||
def display_readable_file_size(size_in_bytes):
|
||||
def display_readable_file_size(size_in_bytes: Union[float, int]) -> str:
|
||||
"""
|
||||
Return readable file size from size in bytes
|
||||
"""
|
||||
if size_in_bytes == 0:
|
||||
return '0 bytes'
|
||||
if size_in_bytes == 1:
|
||||
return '1 byte'
|
||||
for unit in [' bytes', 'KB', 'MB', 'GB', 'TB']:
|
||||
if abs(size_in_bytes) < 1024.0:
|
||||
return f"{size_in_bytes:3.1f}{unit}"
|
||||
return f'{size_in_bytes:3.1f}{unit}'
|
||||
size_in_bytes /= 1024.0
|
||||
return f"{size_in_bytes} bytes"
|
||||
return f'{size_in_bytes} bytes'
|
||||
|
||||
|
||||
def get_readable_duration(duration, locale='en'):
|
||||
def get_readable_duration(duration: int, locale: Optional[str] = 'en') -> str:
|
||||
"""
|
||||
Return readable and localized duration from duration in seconds
|
||||
"""
|
||||
if locale is not None and locale != 'en':
|
||||
_t = humanize.i18n.activate(locale) # noqa
|
||||
readable_duration = humanize.naturaldelta(timedelta(seconds=duration))
|
||||
|
@ -1,19 +1,25 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import jwt
|
||||
from flask import current_app
|
||||
|
||||
|
||||
def get_user_token(user_id, password_reset=False):
|
||||
expiration_days = (
|
||||
0
|
||||
if password_reset
|
||||
else current_app.config.get('TOKEN_EXPIRATION_DAYS')
|
||||
def get_user_token(
|
||||
user_id: int, password_reset: Optional[bool] = False
|
||||
) -> str:
|
||||
"""
|
||||
Return authentication token for a given user.
|
||||
Token expiration time depends on token type (authentication or password
|
||||
reset)
|
||||
"""
|
||||
expiration_days: float = (
|
||||
0.0 if password_reset else current_app.config['TOKEN_EXPIRATION_DAYS']
|
||||
)
|
||||
expiration_seconds = (
|
||||
current_app.config.get('PASSWORD_TOKEN_EXPIRATION_SECONDS')
|
||||
expiration_seconds: float = (
|
||||
current_app.config['PASSWORD_TOKEN_EXPIRATION_SECONDS']
|
||||
if password_reset
|
||||
else current_app.config.get('TOKEN_EXPIRATION_SECONDS')
|
||||
else current_app.config['TOKEN_EXPIRATION_SECONDS']
|
||||
)
|
||||
payload = {
|
||||
'exp': datetime.utcnow()
|
||||
@ -23,15 +29,18 @@ def get_user_token(user_id, password_reset=False):
|
||||
}
|
||||
return jwt.encode(
|
||||
payload,
|
||||
current_app.config.get('SECRET_KEY'),
|
||||
current_app.config['SECRET_KEY'],
|
||||
algorithm='HS256',
|
||||
)
|
||||
|
||||
|
||||
def decode_user_token(auth_token):
|
||||
def decode_user_token(auth_token: str) -> int:
|
||||
"""
|
||||
Return user id from token
|
||||
"""
|
||||
payload = jwt.decode(
|
||||
auth_token,
|
||||
current_app.config.get('SECRET_KEY'),
|
||||
current_app.config['SECRET_KEY'],
|
||||
algorithms=['HS256'],
|
||||
)
|
||||
return payload['sub']
|
||||
|
Reference in New Issue
Block a user