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('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:
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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:
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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')
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user