API - add endpoint to download export archive
This commit is contained in:
parent
90c85f921c
commit
073c677b92
@ -37,7 +37,7 @@ def upgrade():
|
|||||||
sa.Column('completed', sa.Boolean(), nullable=False),
|
sa.Column('completed', sa.Boolean(), nullable=False),
|
||||||
sa.Column('file_name', sa.String(length=100), nullable=True),
|
sa.Column('file_name', sa.String(length=100), nullable=True),
|
||||||
sa.Column('file_size', sa.Integer(), nullable=True),
|
sa.Column('file_size', sa.Integer(), nullable=True),
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.PrimaryKeyConstraint('id')
|
||||||
)
|
)
|
||||||
with op.batch_alter_table('users_data_export', schema=None) as batch_op:
|
with op.batch_alter_table('users_data_export', schema=None) as batch_op:
|
||||||
|
@ -3064,3 +3064,85 @@ class TestGetUserDataExportRequest(ApiTestCaseMixin):
|
|||||||
assert data["request"] == jsonify_dict(
|
assert data["request"] == jsonify_dict(
|
||||||
completed_export_request.serialize()
|
completed_export_request.serialize()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDownloadExportDataArchive(ApiTestCaseMixin):
|
||||||
|
def test_it_returns_404_when_request_export_does_not_exist(
|
||||||
|
self, app: Flask, user_1: User
|
||||||
|
) -> None:
|
||||||
|
client, auth_token = self.get_test_client_and_auth_token(
|
||||||
|
app, user_1.email
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f'/api/auth/profile/export/{self.random_string()}',
|
||||||
|
headers=dict(Authorization=f'Bearer {auth_token}'),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assert_404_with_message(response, 'file not found')
|
||||||
|
|
||||||
|
def test_it_returns_404_when_request_export_from_another_user(
|
||||||
|
self, app: Flask, user_1: User, user_2: User
|
||||||
|
) -> None:
|
||||||
|
archive_file_name = self.random_string()
|
||||||
|
export_request = UserDataExport(user_id=user_2.id)
|
||||||
|
db.session.add(export_request)
|
||||||
|
export_request.completed = True
|
||||||
|
export_request.file_name = archive_file_name
|
||||||
|
db.session.commit()
|
||||||
|
client, auth_token = self.get_test_client_and_auth_token(
|
||||||
|
app, user_1.email
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f'/api/auth/profile/export/{archive_file_name}',
|
||||||
|
headers=dict(Authorization=f'Bearer {auth_token}'),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assert_404_with_message(response, 'file not found')
|
||||||
|
|
||||||
|
def test_it_returns_404_when_file_name_does_not_match(
|
||||||
|
self, app: Flask, user_1: User
|
||||||
|
) -> None:
|
||||||
|
export_request = UserDataExport(user_id=user_1.id)
|
||||||
|
db.session.add(export_request)
|
||||||
|
export_request.completed = True
|
||||||
|
export_request.file_name = self.random_string()
|
||||||
|
db.session.commit()
|
||||||
|
client, auth_token = self.get_test_client_and_auth_token(
|
||||||
|
app, user_1.email
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f'/api/auth/profile/export/{self.random_string()}',
|
||||||
|
headers=dict(Authorization=f'Bearer {auth_token}'),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assert_404_with_message(response, 'file not found')
|
||||||
|
|
||||||
|
def test_it_calls_send_from_directory_if_request_exist(
|
||||||
|
self, app: Flask, user_1: User
|
||||||
|
) -> None:
|
||||||
|
archive_file_name = self.random_string()
|
||||||
|
export_request = UserDataExport(user_id=user_1.id)
|
||||||
|
db.session.add(export_request)
|
||||||
|
export_request.completed = True
|
||||||
|
export_request.file_name = archive_file_name
|
||||||
|
db.session.commit()
|
||||||
|
client, auth_token = self.get_test_client_and_auth_token(
|
||||||
|
app, user_1.email
|
||||||
|
)
|
||||||
|
with patch('fittrackee.users.auth.send_from_directory') as mock:
|
||||||
|
mock.return_value = 'file'
|
||||||
|
|
||||||
|
client.get(
|
||||||
|
f'/api/auth/profile/export/{archive_file_name}',
|
||||||
|
headers=dict(Authorization=f'Bearer {auth_token}'),
|
||||||
|
)
|
||||||
|
|
||||||
|
mock.assert_called_once_with(
|
||||||
|
f"{app.config['UPLOAD_FOLDER']}/exports/{user_1.id}",
|
||||||
|
archive_file_name,
|
||||||
|
mimetype='application/zip',
|
||||||
|
as_attachment=True,
|
||||||
|
)
|
||||||
|
@ -6,7 +6,8 @@ from unittest.mock import MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
from fittrackee.users.models import User, UserSportPreference
|
from fittrackee import db
|
||||||
|
from fittrackee.users.models import User, UserDataExport, UserSportPreference
|
||||||
from fittrackee.utils import get_readable_duration
|
from fittrackee.utils import get_readable_duration
|
||||||
from fittrackee.workouts.models import Sport, Workout
|
from fittrackee.workouts.models import Sport, Workout
|
||||||
|
|
||||||
@ -1551,6 +1552,30 @@ class TestDeleteUser(ApiTestCaseMixin):
|
|||||||
|
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
def test_user_with_export_request_can_delete_its_own_account(
|
||||||
|
self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str
|
||||||
|
) -> None:
|
||||||
|
db.session.add(UserDataExport(user_1.id))
|
||||||
|
db.session.commit()
|
||||||
|
client, auth_token = self.get_test_client_and_auth_token(
|
||||||
|
app, user_1.email
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
'/api/auth/picture',
|
||||||
|
data=dict(file=(BytesIO(b'avatar'), 'avatar.png')),
|
||||||
|
headers=dict(
|
||||||
|
content_type='multipart/form-data',
|
||||||
|
Authorization=f'Bearer {auth_token}',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
'/api/users/test',
|
||||||
|
headers=dict(Authorization=f'Bearer {auth_token}'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
def test_user_can_not_delete_another_user_account(
|
def test_user_can_not_delete_another_user_account(
|
||||||
self, app: Flask, user_1: User, user_2: User
|
self, app: Flask, user_1: User, user_2: User
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@ -371,7 +371,7 @@ class TestExportUserData:
|
|||||||
f"Export id '{export_request.id}' already processed"
|
f"Export id '{export_request.id}' already processed"
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_it_update_export_request_when_export_is_successful(
|
def test_it_updates_export_request_when_export_is_successful(
|
||||||
self,
|
self,
|
||||||
generate_archive_mock: Mock,
|
generate_archive_mock: Mock,
|
||||||
logger_mock: Mock,
|
logger_mock: Mock,
|
||||||
@ -396,7 +396,7 @@ class TestExportUserData:
|
|||||||
assert export_request.file_name == archive_name
|
assert export_request.file_name == archive_name
|
||||||
assert export_request.file_size == archive_size
|
assert export_request.file_size == archive_size
|
||||||
|
|
||||||
def test_it_update_export_request_when_export_fails(
|
def test_it_updates_export_request_when_export_fails(
|
||||||
self,
|
self,
|
||||||
generate_archive_mock: Mock,
|
generate_archive_mock: Mock,
|
||||||
logger_mock: Mock,
|
logger_mock: Mock,
|
||||||
|
@ -5,7 +5,13 @@ import secrets
|
|||||||
from typing import Dict, Optional, Tuple, Union
|
from typing import Dict, Optional, Tuple, Union
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from flask import Blueprint, current_app, request
|
from flask import (
|
||||||
|
Blueprint,
|
||||||
|
Response,
|
||||||
|
current_app,
|
||||||
|
request,
|
||||||
|
send_from_directory,
|
||||||
|
)
|
||||||
from sqlalchemy import exc, func
|
from sqlalchemy import exc, func
|
||||||
from werkzeug.exceptions import RequestEntityTooLarge
|
from werkzeug.exceptions import RequestEntityTooLarge
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
@ -21,6 +27,7 @@ from fittrackee.emails.tasks import (
|
|||||||
from fittrackee.files import get_absolute_file_path
|
from fittrackee.files import get_absolute_file_path
|
||||||
from fittrackee.oauth2.server import require_auth
|
from fittrackee.oauth2.server import require_auth
|
||||||
from fittrackee.responses import (
|
from fittrackee.responses import (
|
||||||
|
DataNotFoundErrorResponse,
|
||||||
ForbiddenErrorResponse,
|
ForbiddenErrorResponse,
|
||||||
HttpResponse,
|
HttpResponse,
|
||||||
InvalidPayloadErrorResponse,
|
InvalidPayloadErrorResponse,
|
||||||
@ -1818,3 +1825,58 @@ def get_user_data_export(auth_user: User) -> Union[Dict, HttpResponse]:
|
|||||||
"status": "success",
|
"status": "success",
|
||||||
"request": export_request.serialize() if export_request else None,
|
"request": export_request.serialize() if export_request else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@auth_blueprint.route(
|
||||||
|
'/auth/profile/export/<string:file_name>', methods=['GET']
|
||||||
|
)
|
||||||
|
@require_auth()
|
||||||
|
def download_data_export(
|
||||||
|
auth_user: User, file_name: str
|
||||||
|
) -> Union[Response, HttpResponse]:
|
||||||
|
"""
|
||||||
|
Download a data export archive
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /auth/profile/export/download/archive_rgjsR3fHr5Yp.zip HTTP/1.1
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Content-Type: application/x-gzip
|
||||||
|
|
||||||
|
:param string file_name: filename
|
||||||
|
|
||||||
|
:reqheader Authorization: OAuth 2.0 Bearer Token
|
||||||
|
|
||||||
|
:statuscode 200: success
|
||||||
|
:statuscode 401:
|
||||||
|
- provide a valid auth token
|
||||||
|
- signature expired, please log in again
|
||||||
|
- invalid token, please log in again
|
||||||
|
:statuscode 404: file not found
|
||||||
|
"""
|
||||||
|
export_request = UserDataExport.query.filter_by(
|
||||||
|
user_id=auth_user.id
|
||||||
|
).first()
|
||||||
|
if (
|
||||||
|
not export_request
|
||||||
|
or not export_request.completed
|
||||||
|
or export_request.file_name != file_name
|
||||||
|
):
|
||||||
|
return DataNotFoundErrorResponse(
|
||||||
|
data_type="archive", message="file not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return send_from_directory(
|
||||||
|
f"{current_app.config['UPLOAD_FOLDER']}/exports/{auth_user.id}",
|
||||||
|
export_request.file_name,
|
||||||
|
mimetype='application/zip',
|
||||||
|
as_attachment=True,
|
||||||
|
)
|
||||||
|
@ -1,14 +1,20 @@
|
|||||||
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Optional, Union
|
from typing import Any, Dict, Optional, Union
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.engine.base import Connection
|
||||||
|
from sqlalchemy.event import listens_for
|
||||||
from sqlalchemy.ext.declarative import DeclarativeMeta
|
from sqlalchemy.ext.declarative import DeclarativeMeta
|
||||||
from sqlalchemy.ext.hybrid import hybrid_property
|
from sqlalchemy.ext.hybrid import hybrid_property
|
||||||
|
from sqlalchemy.orm.mapper import Mapper
|
||||||
|
from sqlalchemy.orm.session import Session
|
||||||
from sqlalchemy.sql.expression import select
|
from sqlalchemy.sql.expression import select
|
||||||
|
|
||||||
from fittrackee import bcrypt, db
|
from fittrackee import appLog, bcrypt, db
|
||||||
|
from fittrackee.files import get_absolute_file_path
|
||||||
from fittrackee.workouts.models import Workout
|
from fittrackee.workouts.models import Workout
|
||||||
|
|
||||||
from .exceptions import UserNotFoundException
|
from .exceptions import UserNotFoundException
|
||||||
@ -284,7 +290,7 @@ class UserDataExport(BaseModel):
|
|||||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||||
user_id = db.Column(
|
user_id = db.Column(
|
||||||
db.Integer,
|
db.Integer,
|
||||||
db.ForeignKey('users.id'),
|
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||||
index=True,
|
index=True,
|
||||||
unique=True,
|
unique=True,
|
||||||
)
|
)
|
||||||
@ -319,3 +325,19 @@ class UserDataExport(BaseModel):
|
|||||||
"file_name": self.file_name if status == "successful" else None,
|
"file_name": self.file_name if status == "successful" else None,
|
||||||
"file_size": self.file_size if status == "successful" else None,
|
"file_size": self.file_size if status == "successful" else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@listens_for(UserDataExport, 'after_delete')
|
||||||
|
def on_users_data_export_delete(
|
||||||
|
mapper: Mapper, connection: Connection, old_record: 'UserDataExport'
|
||||||
|
) -> None:
|
||||||
|
@listens_for(db.Session, 'after_flush', once=True)
|
||||||
|
def receive_after_flush(session: Session, context: Any) -> None:
|
||||||
|
if old_record.file_name:
|
||||||
|
try:
|
||||||
|
file_path = (
|
||||||
|
f"exports/{old_record.user_id}/{old_record.file_name}"
|
||||||
|
)
|
||||||
|
os.remove(get_absolute_file_path(file_path))
|
||||||
|
except OSError:
|
||||||
|
appLog.error('archive found when deleting export request')
|
||||||
|
@ -26,7 +26,7 @@ from fittrackee.workouts.models import Record, Workout, WorkoutSegment
|
|||||||
|
|
||||||
from .auth import get_language
|
from .auth import get_language
|
||||||
from .exceptions import InvalidEmailException, UserNotFoundException
|
from .exceptions import InvalidEmailException, UserNotFoundException
|
||||||
from .models import User, UserSportPreference
|
from .models import User, UserDataExport, UserSportPreference
|
||||||
from .utils.admin import UserManagerService
|
from .utils.admin import UserManagerService
|
||||||
|
|
||||||
users_blueprint = Blueprint('users', __name__)
|
users_blueprint = Blueprint('users', __name__)
|
||||||
@ -667,6 +667,9 @@ def delete_user(
|
|||||||
WorkoutSegment.workout_id == Workout.id, Workout.user_id == user.id
|
WorkoutSegment.workout_id == Workout.id, Workout.user_id == user.id
|
||||||
).delete(synchronize_session=False)
|
).delete(synchronize_session=False)
|
||||||
db.session.query(Workout).filter(Workout.user_id == user.id).delete()
|
db.session.query(Workout).filter(Workout.user_id == user.id).delete()
|
||||||
|
db.session.query(UserDataExport).filter(
|
||||||
|
UserDataExport.user_id == user.id
|
||||||
|
).delete()
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
user_picture = user.picture
|
user_picture = user.picture
|
||||||
db.session.delete(user)
|
db.session.delete(user)
|
||||||
@ -675,6 +678,10 @@ def delete_user(
|
|||||||
picture_path = get_absolute_file_path(user.picture)
|
picture_path = get_absolute_file_path(user.picture)
|
||||||
if os.path.isfile(picture_path):
|
if os.path.isfile(picture_path):
|
||||||
os.remove(picture_path)
|
os.remove(picture_path)
|
||||||
|
shutil.rmtree(
|
||||||
|
get_absolute_file_path(f'exports/{user.id}'),
|
||||||
|
ignore_errors=True,
|
||||||
|
)
|
||||||
shutil.rmtree(
|
shutil.rmtree(
|
||||||
get_absolute_file_path(f'workouts/{user.id}'),
|
get_absolute_file_path(f'workouts/{user.id}'),
|
||||||
ignore_errors=True,
|
ignore_errors=True,
|
||||||
|
Loading…
Reference in New Issue
Block a user