API - add endpoint to download export archive
This commit is contained in:
		@@ -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,
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user