API - init user data export
This commit is contained in:
parent
ce67924539
commit
948424045f
335
fittrackee/tests/users/test_export_data.py
Normal file
335
fittrackee/tests/users/test_export_data.py
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from unittest.mock import Mock, call, patch
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from fittrackee.users.export_data import UserDataExporter
|
||||||
|
from fittrackee.users.models import User
|
||||||
|
from fittrackee.workouts.models import Sport, Workout
|
||||||
|
|
||||||
|
from ..mixins import CallArgsMixin
|
||||||
|
from ..utils import random_string
|
||||||
|
from ..workouts.utils import post_a_workout
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserDataExporterGetData:
|
||||||
|
def test_it_return_serialized_user_as_info_info(
|
||||||
|
self, app: Flask, user_1: User
|
||||||
|
) -> None:
|
||||||
|
exporter = UserDataExporter(user_1)
|
||||||
|
|
||||||
|
user_data = exporter.get_user_info()
|
||||||
|
|
||||||
|
assert user_data == user_1.serialize(user_1)
|
||||||
|
|
||||||
|
def test_it_returns_empty_list_when_user_has_no_workouts(
|
||||||
|
self, app: Flask, user_1: User
|
||||||
|
) -> None:
|
||||||
|
exporter = UserDataExporter(user_1)
|
||||||
|
|
||||||
|
workouts_data = exporter.get_user_workouts_data()
|
||||||
|
|
||||||
|
assert workouts_data == []
|
||||||
|
|
||||||
|
def test_it_returns_data_for_workout_without_gpx(
|
||||||
|
self,
|
||||||
|
app: Flask,
|
||||||
|
user_1: User,
|
||||||
|
sport_1_cycling: Sport,
|
||||||
|
workout_cycling_user_1: Workout,
|
||||||
|
) -> None:
|
||||||
|
exporter = UserDataExporter(user_1)
|
||||||
|
|
||||||
|
workouts_data = exporter.get_user_workouts_data()
|
||||||
|
|
||||||
|
assert workouts_data == [
|
||||||
|
{
|
||||||
|
'id': workout_cycling_user_1.short_id,
|
||||||
|
'sport_id': sport_1_cycling.id,
|
||||||
|
'sport_label': sport_1_cycling.label,
|
||||||
|
'title': workout_cycling_user_1.title,
|
||||||
|
'creation_date': workout_cycling_user_1.creation_date,
|
||||||
|
'modification_date': workout_cycling_user_1.modification_date,
|
||||||
|
'workout_date': workout_cycling_user_1.workout_date,
|
||||||
|
'duration': str(workout_cycling_user_1.duration),
|
||||||
|
'pauses': None,
|
||||||
|
'moving': str(workout_cycling_user_1.moving),
|
||||||
|
'distance': float(workout_cycling_user_1.distance),
|
||||||
|
'min_alt': None,
|
||||||
|
'max_alt': None,
|
||||||
|
'descent': None,
|
||||||
|
'ascent': None,
|
||||||
|
'max_speed': float(workout_cycling_user_1.max_speed),
|
||||||
|
'ave_speed': float(workout_cycling_user_1.ave_speed),
|
||||||
|
'gpx': None,
|
||||||
|
'records': [
|
||||||
|
record.serialize()
|
||||||
|
for record in workout_cycling_user_1.records
|
||||||
|
],
|
||||||
|
'segments': [],
|
||||||
|
'weather_start': None,
|
||||||
|
'weather_end': None,
|
||||||
|
'notes': workout_cycling_user_1.notes,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_it_returns_data_for_workout_with_gpx(
|
||||||
|
self,
|
||||||
|
app: Flask,
|
||||||
|
user_1: User,
|
||||||
|
sport_1_cycling: Sport,
|
||||||
|
gpx_file: str,
|
||||||
|
) -> None:
|
||||||
|
_, workout_short_id = post_a_workout(app, gpx_file)
|
||||||
|
workout = Workout.query.first()
|
||||||
|
exporter = UserDataExporter(user_1)
|
||||||
|
|
||||||
|
workouts_data = exporter.get_user_workouts_data()
|
||||||
|
|
||||||
|
assert workouts_data == [
|
||||||
|
{
|
||||||
|
'id': workout.short_id,
|
||||||
|
'sport_id': sport_1_cycling.id,
|
||||||
|
'sport_label': sport_1_cycling.label,
|
||||||
|
'title': workout.title,
|
||||||
|
'creation_date': workout.creation_date,
|
||||||
|
'modification_date': workout.modification_date,
|
||||||
|
'workout_date': workout.workout_date,
|
||||||
|
'duration': str(workout.duration),
|
||||||
|
'pauses': None,
|
||||||
|
'moving': str(workout.moving),
|
||||||
|
'distance': float(workout.distance),
|
||||||
|
'min_alt': float(workout.min_alt),
|
||||||
|
'max_alt': float(workout.max_alt),
|
||||||
|
'descent': float(workout.descent),
|
||||||
|
'ascent': float(workout.ascent),
|
||||||
|
'max_speed': float(workout.max_speed),
|
||||||
|
'ave_speed': float(workout.ave_speed),
|
||||||
|
'gpx': workout.gpx.split('/')[-1],
|
||||||
|
'records': [record.serialize() for record in workout.records],
|
||||||
|
'segments': [
|
||||||
|
segment.serialize() for segment in workout.segments
|
||||||
|
],
|
||||||
|
'weather_start': None,
|
||||||
|
'weather_end': None,
|
||||||
|
'notes': workout.notes,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_it_stores_only_user_workouts(
|
||||||
|
self,
|
||||||
|
app: Flask,
|
||||||
|
user_1: User,
|
||||||
|
user_2: User,
|
||||||
|
sport_1_cycling: Sport,
|
||||||
|
workout_cycling_user_1: Workout,
|
||||||
|
workout_cycling_user_2: Workout,
|
||||||
|
) -> None:
|
||||||
|
exporter = UserDataExporter(user_1)
|
||||||
|
|
||||||
|
workouts_data = exporter.get_user_workouts_data()
|
||||||
|
|
||||||
|
assert [data["id"] for data in workouts_data] == [
|
||||||
|
workout_cycling_user_1.short_id
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_export_data_generates_json_file_in_user_directory(
|
||||||
|
self,
|
||||||
|
app: Flask,
|
||||||
|
user_1: User,
|
||||||
|
) -> None:
|
||||||
|
data = {"foo": "bar"}
|
||||||
|
export = UserDataExporter(user_1)
|
||||||
|
user_directory = os.path.join(
|
||||||
|
app.config['UPLOAD_FOLDER'], 'exports', str(user_1.id)
|
||||||
|
)
|
||||||
|
os.makedirs(user_directory, exist_ok=True)
|
||||||
|
file_name = random_string()
|
||||||
|
|
||||||
|
export.export_data(data, file_name)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
os.path.isfile(os.path.join(user_directory, f"{file_name}.json"))
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_it_returns_json_file_path(
|
||||||
|
self,
|
||||||
|
app: Flask,
|
||||||
|
user_1: User,
|
||||||
|
) -> None:
|
||||||
|
data = {"foo": "bar"}
|
||||||
|
exporter = UserDataExporter(user_1)
|
||||||
|
user_directory = os.path.join(
|
||||||
|
app.config['UPLOAD_FOLDER'], 'exports', str(user_1.id)
|
||||||
|
)
|
||||||
|
file_name = random_string()
|
||||||
|
|
||||||
|
file_path = exporter.export_data(data, file_name)
|
||||||
|
|
||||||
|
assert file_path == os.path.join(user_directory, f"{file_name}.json")
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserDataExporterArchive(CallArgsMixin):
|
||||||
|
@patch.object(secrets, 'token_urlsafe')
|
||||||
|
@patch.object(UserDataExporter, 'export_data')
|
||||||
|
@patch('fittrackee.users.export_data.ZipFile')
|
||||||
|
def test_it_calls_export_data_twice(
|
||||||
|
self,
|
||||||
|
zipfile_mock: Mock,
|
||||||
|
export_data: Mock,
|
||||||
|
secrets_mock: Mock,
|
||||||
|
app: Flask,
|
||||||
|
user_1: User,
|
||||||
|
) -> None:
|
||||||
|
exporter = UserDataExporter(user_1)
|
||||||
|
|
||||||
|
exporter.generate_archive()
|
||||||
|
|
||||||
|
export_data.assert_has_calls(
|
||||||
|
[
|
||||||
|
call(exporter.get_user_info(), 'user_data'),
|
||||||
|
call(exporter.get_user_workouts_data(), 'workouts_data'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch.object(secrets, 'token_urlsafe')
|
||||||
|
@patch.object(UserDataExporter, 'export_data')
|
||||||
|
@patch('fittrackee.users.export_data.ZipFile')
|
||||||
|
def test_it_calls_zipfile_with_expected_patch(
|
||||||
|
self,
|
||||||
|
zipfile_mock: Mock,
|
||||||
|
export_data: Mock,
|
||||||
|
secrets_mock: Mock,
|
||||||
|
app: Flask,
|
||||||
|
user_1: User,
|
||||||
|
) -> None:
|
||||||
|
exporter = UserDataExporter(user_1)
|
||||||
|
token_urlsafe = random_string()
|
||||||
|
secrets_mock.return_value = token_urlsafe
|
||||||
|
expected_path = os.path.join(
|
||||||
|
app.config['UPLOAD_FOLDER'],
|
||||||
|
'exports',
|
||||||
|
str(user_1.id),
|
||||||
|
f"archive_{token_urlsafe}.zip",
|
||||||
|
)
|
||||||
|
|
||||||
|
exporter.generate_archive()
|
||||||
|
|
||||||
|
zipfile_mock.assert_called_once_with(expected_path, 'w')
|
||||||
|
|
||||||
|
@patch.object(secrets, 'token_urlsafe')
|
||||||
|
@patch.object(UserDataExporter, 'export_data')
|
||||||
|
@patch('fittrackee.users.export_data.ZipFile')
|
||||||
|
def test_it_calls_zipfile_for_each_json_file(
|
||||||
|
self,
|
||||||
|
zipfile_mock: Mock,
|
||||||
|
export_data: Mock,
|
||||||
|
secrets_mock: Mock,
|
||||||
|
app: Flask,
|
||||||
|
user_1: User,
|
||||||
|
) -> None:
|
||||||
|
exporter = UserDataExporter(user_1)
|
||||||
|
token_urlsafe = random_string()
|
||||||
|
secrets_mock.return_value = token_urlsafe
|
||||||
|
export_data.side_effect = [call('user_info'), call('workouts_data')]
|
||||||
|
|
||||||
|
exporter.generate_archive()
|
||||||
|
|
||||||
|
# fmt: off
|
||||||
|
zipfile_mock.return_value.__enter__\
|
||||||
|
.return_value.write.assert_has_calls(
|
||||||
|
[
|
||||||
|
call(call('user_info'), 'user_data.json'),
|
||||||
|
call(call('workouts_data'), 'user_workouts_data.json'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
|
@patch.object(secrets, 'token_urlsafe')
|
||||||
|
@patch.object(UserDataExporter, 'export_data')
|
||||||
|
@patch('fittrackee.users.export_data.ZipFile')
|
||||||
|
def test_it_calls_zipfile_for_gpx_file(
|
||||||
|
self,
|
||||||
|
zipfile_mock: Mock,
|
||||||
|
export_data: Mock,
|
||||||
|
secrets_mock: Mock,
|
||||||
|
app: Flask,
|
||||||
|
user_1: User,
|
||||||
|
sport_1_cycling: Sport,
|
||||||
|
gpx_file: str,
|
||||||
|
) -> None:
|
||||||
|
_, workout_short_id = post_a_workout(app, gpx_file)
|
||||||
|
workout = Workout.query.first()
|
||||||
|
expected_path = os.path.join(
|
||||||
|
app.config['UPLOAD_FOLDER'],
|
||||||
|
workout.gpx,
|
||||||
|
)
|
||||||
|
exporter = UserDataExporter(user_1)
|
||||||
|
|
||||||
|
exporter.generate_archive()
|
||||||
|
|
||||||
|
# fmt: off
|
||||||
|
zipfile_mock.return_value.__enter__.\
|
||||||
|
return_value.write.assert_has_calls(
|
||||||
|
[
|
||||||
|
call(expected_path, f"gpx/{workout.gpx.split('/')[-1]}"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
|
@patch.object(secrets, 'token_urlsafe')
|
||||||
|
@patch.object(UserDataExporter, 'export_data')
|
||||||
|
@patch('fittrackee.users.export_data.ZipFile')
|
||||||
|
def test_it_calls_zipfile_for_profile_image_when_exists(
|
||||||
|
self,
|
||||||
|
zipfile_mock: Mock,
|
||||||
|
export_data: Mock,
|
||||||
|
secrets_mock: Mock,
|
||||||
|
app: Flask,
|
||||||
|
user_1: User,
|
||||||
|
sport_1_cycling: Sport,
|
||||||
|
gpx_file: str,
|
||||||
|
) -> None:
|
||||||
|
user_1.picture = random_string()
|
||||||
|
expected_path = os.path.join(
|
||||||
|
app.config['UPLOAD_FOLDER'],
|
||||||
|
user_1.picture,
|
||||||
|
)
|
||||||
|
exporter = UserDataExporter(user_1)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
'fittrackee.users.export_data.os.path.isfile', return_value=True
|
||||||
|
):
|
||||||
|
exporter.generate_archive()
|
||||||
|
|
||||||
|
# fmt: off
|
||||||
|
zipfile_mock.return_value.__enter__.\
|
||||||
|
return_value.write.assert_has_calls(
|
||||||
|
[
|
||||||
|
call(expected_path, user_1.picture.split('/')[-1]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
|
@patch.object(secrets, 'token_urlsafe')
|
||||||
|
def test_it_test_it_generates_a_zip_archive(
|
||||||
|
self,
|
||||||
|
secrets_mock: Mock,
|
||||||
|
app: Flask,
|
||||||
|
user_1: User,
|
||||||
|
) -> None:
|
||||||
|
token_urlsafe = random_string()
|
||||||
|
secrets_mock.return_value = token_urlsafe
|
||||||
|
expected_path = os.path.join(
|
||||||
|
app.config['UPLOAD_FOLDER'],
|
||||||
|
'exports',
|
||||||
|
str(user_1.id),
|
||||||
|
f"archive_{token_urlsafe}.zip",
|
||||||
|
)
|
||||||
|
exporter = UserDataExporter(user_1)
|
||||||
|
|
||||||
|
exporter.generate_archive()
|
||||||
|
|
||||||
|
assert os.path.isfile(expected_path)
|
86
fittrackee/users/export_data.py
Normal file
86
fittrackee/users/export_data.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from typing import Dict, List, Optional, Union
|
||||||
|
from zipfile import ZipFile
|
||||||
|
|
||||||
|
from fittrackee.files import get_absolute_file_path
|
||||||
|
|
||||||
|
from .models import User
|
||||||
|
|
||||||
|
|
||||||
|
class UserDataExporter:
|
||||||
|
"""
|
||||||
|
generates a zip archive with:
|
||||||
|
- user info from database (json file)
|
||||||
|
- data from database for all workouts if exist (json file)
|
||||||
|
- profile picture file if exists
|
||||||
|
- gpx files if exist
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, user: User) -> None:
|
||||||
|
self.user = user
|
||||||
|
self.export_directory = get_absolute_file_path(
|
||||||
|
os.path.join('exports', str(self.user.id))
|
||||||
|
)
|
||||||
|
os.makedirs(self.export_directory, exist_ok=True)
|
||||||
|
self.workouts_directory = get_absolute_file_path(
|
||||||
|
os.path.join('workouts', str(self.user.id))
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_user_info(self) -> Dict:
|
||||||
|
return self.user.serialize(self.user)
|
||||||
|
|
||||||
|
def get_user_workouts_data(self) -> List[Dict]:
|
||||||
|
workouts_data = []
|
||||||
|
for workout in self.user.workouts:
|
||||||
|
workout_data = workout.get_workout_data()
|
||||||
|
workout_data["sport_label"] = workout.sport.label
|
||||||
|
workout_data["gpx"] = (
|
||||||
|
workout.gpx.split('/')[-1] if workout.gpx else None
|
||||||
|
)
|
||||||
|
workouts_data.append(workout_data)
|
||||||
|
return workouts_data
|
||||||
|
|
||||||
|
def export_data(self, data: Union[Dict, List], name: str) -> str:
|
||||||
|
"""export data in existing user upload directory"""
|
||||||
|
json_object = json.dumps(data, indent=4, default=str)
|
||||||
|
file_path = os.path.join(self.export_directory, f"{name}.json")
|
||||||
|
with open(file_path, "w") as export_file:
|
||||||
|
export_file.write(json_object)
|
||||||
|
return file_path
|
||||||
|
|
||||||
|
def generate_archive(self) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
user_data_file_name = self.export_data(
|
||||||
|
self.get_user_info(), "user_data"
|
||||||
|
)
|
||||||
|
workout_data_file_name = self.export_data(
|
||||||
|
self.get_user_workouts_data(), "workouts_data"
|
||||||
|
)
|
||||||
|
zip_file = f"archive_{secrets.token_urlsafe(15)}.zip"
|
||||||
|
zip_path = os.path.join(self.export_directory, zip_file)
|
||||||
|
with ZipFile(zip_path, 'w') as zip_object:
|
||||||
|
zip_object.write(user_data_file_name, "user_data.json")
|
||||||
|
zip_object.write(
|
||||||
|
workout_data_file_name, "user_workouts_data.json"
|
||||||
|
)
|
||||||
|
if self.user.picture:
|
||||||
|
picture_path = get_absolute_file_path(self.user.picture)
|
||||||
|
if os.path.isfile(picture_path):
|
||||||
|
zip_object.write(
|
||||||
|
picture_path, self.user.picture.split('/')[-1]
|
||||||
|
)
|
||||||
|
for file in os.listdir(self.workouts_directory):
|
||||||
|
if os.path.isfile(
|
||||||
|
os.path.join(self.workouts_directory, file)
|
||||||
|
) and file.endswith('.gpx'):
|
||||||
|
zip_object.write(
|
||||||
|
os.path.join(self.workouts_directory, file),
|
||||||
|
f"gpx/{file}",
|
||||||
|
)
|
||||||
|
|
||||||
|
file_exists = os.path.exists(zip_path)
|
||||||
|
return zip_file if file_exists else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
@ -192,6 +192,33 @@ class Workout(BaseModel):
|
|||||||
def short_id(self) -> str:
|
def short_id(self) -> str:
|
||||||
return encode_uuid(self.uuid)
|
return encode_uuid(self.uuid)
|
||||||
|
|
||||||
|
def get_workout_data(self) -> Dict:
|
||||||
|
return {
|
||||||
|
'id': self.short_id, # WARNING: client use uuid as id
|
||||||
|
'sport_id': self.sport_id,
|
||||||
|
'title': self.title,
|
||||||
|
'creation_date': self.creation_date,
|
||||||
|
'modification_date': self.modification_date,
|
||||||
|
'workout_date': self.workout_date,
|
||||||
|
'duration': str(self.duration) if self.duration else None,
|
||||||
|
'pauses': str(self.pauses) if self.pauses else None,
|
||||||
|
'moving': str(self.moving) if self.moving else None,
|
||||||
|
'distance': float(self.distance) if self.distance else None,
|
||||||
|
'min_alt': float(self.min_alt) if self.min_alt else None,
|
||||||
|
'max_alt': float(self.max_alt) if self.max_alt else None,
|
||||||
|
'descent': float(self.descent)
|
||||||
|
if self.descent is not None
|
||||||
|
else None,
|
||||||
|
'ascent': float(self.ascent) if self.ascent is not None else None,
|
||||||
|
'max_speed': float(self.max_speed) if self.max_speed else None,
|
||||||
|
'ave_speed': float(self.ave_speed) if self.ave_speed else None,
|
||||||
|
'records': [record.serialize() for record in self.records],
|
||||||
|
'segments': [segment.serialize() for segment in self.segments],
|
||||||
|
'weather_start': self.weather_start,
|
||||||
|
'weather_end': self.weather_end,
|
||||||
|
'notes': self.notes,
|
||||||
|
}
|
||||||
|
|
||||||
def serialize(self, params: Optional[Dict] = None) -> Dict:
|
def serialize(self, params: Optional[Dict] = None) -> Dict:
|
||||||
date_from = params.get('from') if params else None
|
date_from = params.get('from') if params else None
|
||||||
date_to = params.get('to') if params else None
|
date_to = params.get('to') if params else None
|
||||||
@ -282,41 +309,21 @@ class Workout(BaseModel):
|
|||||||
.order_by(Workout.workout_date.asc())
|
.order_by(Workout.workout_date.asc())
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
return {
|
|
||||||
'id': self.short_id, # WARNING: client use uuid as id
|
workout = self.get_workout_data()
|
||||||
'user': self.user.username,
|
workout["next_workout"] = (
|
||||||
'sport_id': self.sport_id,
|
next_workout.short_id if next_workout else None
|
||||||
'title': self.title,
|
)
|
||||||
'creation_date': self.creation_date,
|
workout["previous_workout"] = (
|
||||||
'modification_date': self.modification_date,
|
previous_workout.short_id if previous_workout else None
|
||||||
'workout_date': self.workout_date,
|
)
|
||||||
'duration': str(self.duration) if self.duration else None,
|
workout["bounds"] = (
|
||||||
'pauses': str(self.pauses) if self.pauses else None,
|
[float(bound) for bound in self.bounds] if self.bounds else []
|
||||||
'moving': str(self.moving) if self.moving else None,
|
)
|
||||||
'distance': float(self.distance) if self.distance else None,
|
workout["user"] = self.user.username
|
||||||
'min_alt': float(self.min_alt) if self.min_alt else None,
|
workout["map"] = self.map_id if self.map else None
|
||||||
'max_alt': float(self.max_alt) if self.max_alt else None,
|
workout["with_gpx"] = self.gpx is not None
|
||||||
'descent': float(self.descent)
|
return workout
|
||||||
if self.descent is not None
|
|
||||||
else None,
|
|
||||||
'ascent': float(self.ascent) if self.ascent is not None else None,
|
|
||||||
'max_speed': float(self.max_speed) if self.max_speed else None,
|
|
||||||
'ave_speed': float(self.ave_speed) if self.ave_speed else None,
|
|
||||||
'with_gpx': self.gpx is not None,
|
|
||||||
'bounds': [float(bound) for bound in self.bounds]
|
|
||||||
if self.bounds
|
|
||||||
else [], # noqa
|
|
||||||
'previous_workout': previous_workout.short_id
|
|
||||||
if previous_workout
|
|
||||||
else None, # noqa
|
|
||||||
'next_workout': next_workout.short_id if next_workout else None,
|
|
||||||
'segments': [segment.serialize() for segment in self.segments],
|
|
||||||
'records': [record.serialize() for record in self.records],
|
|
||||||
'map': self.map_id if self.map else None,
|
|
||||||
'weather_start': self.weather_start,
|
|
||||||
'weather_end': self.weather_end,
|
|
||||||
'notes': self.notes,
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_user_workout_records(
|
def get_user_workout_records(
|
||||||
|
Loading…
Reference in New Issue
Block a user