API - add endpoint to download export archive

This commit is contained in:
Sam 2023-03-01 21:00:53 +01:00
parent 90c85f921c
commit 073c677b92
7 changed files with 207 additions and 9 deletions

View File

@ -37,7 +37,7 @@ def upgrade():
sa.Column('completed', sa.Boolean(), nullable=False),
sa.Column('file_name', sa.String(length=100), 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')
)
with op.batch_alter_table('users_data_export', schema=None) as batch_op:

View File

@ -3064,3 +3064,85 @@ class TestGetUserDataExportRequest(ApiTestCaseMixin):
assert data["request"] == jsonify_dict(
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,
)

View File

@ -6,7 +6,8 @@ from unittest.mock import MagicMock, patch
import pytest
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.workouts.models import Sport, Workout
@ -1551,6 +1552,30 @@ class TestDeleteUser(ApiTestCaseMixin):
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(
self, app: Flask, user_1: User, user_2: User
) -> None:

View File

@ -371,7 +371,7 @@ class TestExportUserData:
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,
generate_archive_mock: Mock,
logger_mock: Mock,
@ -396,7 +396,7 @@ class TestExportUserData:
assert export_request.file_name == archive_name
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,
generate_archive_mock: Mock,
logger_mock: Mock,

View File

@ -5,7 +5,13 @@ import secrets
from typing import Dict, Optional, Tuple, Union
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 werkzeug.exceptions import RequestEntityTooLarge
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.oauth2.server import require_auth
from fittrackee.responses import (
DataNotFoundErrorResponse,
ForbiddenErrorResponse,
HttpResponse,
InvalidPayloadErrorResponse,
@ -1818,3 +1825,58 @@ def get_user_data_export(auth_user: User) -> Union[Dict, HttpResponse]:
"status": "success",
"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,
)

View File

@ -1,14 +1,20 @@
import os
from datetime import datetime
from typing import Dict, Optional, Union
from typing import Any, Dict, Optional, Union
import jwt
from flask import current_app
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.hybrid import hybrid_property
from sqlalchemy.orm.mapper import Mapper
from sqlalchemy.orm.session import Session
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 .exceptions import UserNotFoundException
@ -284,7 +290,7 @@ class UserDataExport(BaseModel):
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
user_id = db.Column(
db.Integer,
db.ForeignKey('users.id'),
db.ForeignKey('users.id', ondelete='CASCADE'),
index=True,
unique=True,
)
@ -319,3 +325,19 @@ class UserDataExport(BaseModel):
"file_name": self.file_name 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')

View File

@ -26,7 +26,7 @@ from fittrackee.workouts.models import Record, Workout, WorkoutSegment
from .auth import get_language
from .exceptions import InvalidEmailException, UserNotFoundException
from .models import User, UserSportPreference
from .models import User, UserDataExport, UserSportPreference
from .utils.admin import UserManagerService
users_blueprint = Blueprint('users', __name__)
@ -667,6 +667,9 @@ def delete_user(
WorkoutSegment.workout_id == Workout.id, Workout.user_id == user.id
).delete(synchronize_session=False)
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()
user_picture = user.picture
db.session.delete(user)
@ -675,6 +678,10 @@ def delete_user(
picture_path = get_absolute_file_path(user.picture)
if os.path.isfile(picture_path):
os.remove(picture_path)
shutil.rmtree(
get_absolute_file_path(f'exports/{user.id}'),
ignore_errors=True,
)
shutil.rmtree(
get_absolute_file_path(f'workouts/{user.id}'),
ignore_errors=True,