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('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:

View File

@ -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,
)

View File

@ -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:

View File

@ -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,

View File

@ -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,
)

View File

@ -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')

View File

@ -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,