diff --git a/fittrackee/migrations/versions/30_374a670efe23_add_privacy_policy.py b/fittrackee/migrations/versions/30_374a670efe23_add_privacy_policy.py index d2fc07a5..4159d6b7 100644 --- a/fittrackee/migrations/versions/30_374a670efe23_add_privacy_policy.py +++ b/fittrackee/migrations/versions/30_374a670efe23_add_privacy_policy.py @@ -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: diff --git a/fittrackee/tests/users/test_auth_api.py b/fittrackee/tests/users/test_auth_api.py index b7a7b032..e0498901 100644 --- a/fittrackee/tests/users/test_auth_api.py +++ b/fittrackee/tests/users/test_auth_api.py @@ -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, + ) diff --git a/fittrackee/tests/users/test_users_api.py b/fittrackee/tests/users/test_users_api.py index d66008ea..b28d4d3a 100644 --- a/fittrackee/tests/users/test_users_api.py +++ b/fittrackee/tests/users/test_users_api.py @@ -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: diff --git a/fittrackee/tests/users/test_users_export_data.py b/fittrackee/tests/users/test_users_export_data.py index 617fef1b..85e783be 100644 --- a/fittrackee/tests/users/test_users_export_data.py +++ b/fittrackee/tests/users/test_users_export_data.py @@ -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, diff --git a/fittrackee/users/auth.py b/fittrackee/users/auth.py index 6fce2839..06a2b570 100644 --- a/fittrackee/users/auth.py +++ b/fittrackee/users/auth.py @@ -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/', 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, + ) diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index 7b6d3c09..4d4b46f9 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -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') diff --git a/fittrackee/users/users.py b/fittrackee/users/users.py index 149d225f..c5801c23 100644 --- a/fittrackee/users/users.py +++ b/fittrackee/users/users.py @@ -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,