From 4e3d2f98cf7100a841dad593c60336496f696afc Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 25 Feb 2023 14:06:49 +0100 Subject: [PATCH] API - init privacy policy --- fittrackee/application/app_config.py | 14 +++ fittrackee/application/models.py | 6 ++ .../30_374a670efe23_add_privacy_policy.py | 48 ++++++++++ .../tests/application/test_app_config_api.py | 64 ++++++++++++- .../application/test_app_config_model.py | 27 ++++++ fittrackee/tests/fixtures/fixtures_users.py | 9 ++ fittrackee/tests/users/test_auth_api.py | 90 ++++++++++++++++--- fittrackee/tests/users/test_users_api.py | 6 +- fittrackee/tests/users/test_users_model.py | 17 ++++ fittrackee/users/auth.py | 11 +++ fittrackee/users/models.py | 2 + 11 files changed, 279 insertions(+), 15 deletions(-) create mode 100644 fittrackee/migrations/versions/30_374a670efe23_add_privacy_policy.py diff --git a/fittrackee/application/app_config.py b/fittrackee/application/app_config.py index 55bdda35..b3e480c9 100644 --- a/fittrackee/application/app_config.py +++ b/fittrackee/application/app_config.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Dict, Union from flask import Blueprint, current_app, request @@ -40,6 +41,7 @@ def get_application_config() -> Union[Dict, HttpResponse]: { "data": { + "about": null, "admin_contact": "admin@example.com", "gpx_limit_import": 10, "is_email_sending_enabled": true, @@ -48,6 +50,8 @@ def get_application_config() -> Union[Dict, HttpResponse]: "max_users": 0, "max_zip_file_size": 10485760, "map_attribution": "© OpenStreetMap contributors", + "privacy_policy": null, + "privacy_policy_date": null, "version": "0.7.12", "weather_provider": null }, @@ -93,6 +97,7 @@ def update_application_config(auth_user: User) -> Union[Dict, HttpResponse]: { "data": { + "about": null, "admin_contact": "admin@example.com", "gpx_limit_import": 10, "is_email_sending_enabled": true, @@ -101,18 +106,22 @@ def update_application_config(auth_user: User) -> Union[Dict, HttpResponse]: "max_users": 10, "max_zip_file_size": 10485760, "map_attribution": "© OpenStreetMap contributors", + "privacy_policy": null, + "privacy_policy_date": null, "version": "0.7.12", "weather_provider": null }, "status": "success" } + : Union[Dict, HttpResponse]: config.max_users = config_data.get('max_users') if 'admin_contact' in config_data: config.admin_contact = admin_contact if admin_contact else None + if 'about' in config_data: + config.about = config_data.get('about') + if 'privacy_policy' in config_data: + config.privacy_policy = config_data.get('privacy_policy') + config.privacy_policy_date = datetime.utcnow() if config.max_zip_file_size < config.max_single_file_size: return InvalidPayloadErrorResponse( diff --git a/fittrackee/application/models.py b/fittrackee/application/models.py index 7d8302b5..0e8dcc72 100644 --- a/fittrackee/application/models.py +++ b/fittrackee/application/models.py @@ -25,6 +25,9 @@ class AppConfig(BaseModel): ) max_zip_file_size = db.Column(db.Integer, default=10485760, nullable=False) admin_contact = db.Column(db.String(255), nullable=True) + privacy_policy_date = db.Column(db.DateTime, nullable=True) + privacy_policy = db.Column(db.Text, nullable=True) + about = db.Column(db.Text, nullable=True) @property def is_registration_enabled(self) -> bool: @@ -46,6 +49,7 @@ class AppConfig(BaseModel): def serialize(self) -> Dict: weather_provider = os.getenv('WEATHER_API_PROVIDER', '').lower() return { + 'about': self.about, 'admin_contact': self.admin_contact, 'gpx_limit_import': self.gpx_limit_import, 'is_email_sending_enabled': current_app.config['CAN_SEND_EMAILS'], @@ -54,6 +58,8 @@ class AppConfig(BaseModel): 'max_zip_file_size': self.max_zip_file_size, 'max_users': self.max_users, 'map_attribution': self.map_attribution, + 'privacy_policy': self.privacy_policy, + 'privacy_policy_date': self.privacy_policy_date, 'version': VERSION, 'weather_provider': ( weather_provider diff --git a/fittrackee/migrations/versions/30_374a670efe23_add_privacy_policy.py b/fittrackee/migrations/versions/30_374a670efe23_add_privacy_policy.py new file mode 100644 index 00000000..ee595744 --- /dev/null +++ b/fittrackee/migrations/versions/30_374a670efe23_add_privacy_policy.py @@ -0,0 +1,48 @@ +"""add privacy policy + +Revision ID: 374a670efe23 +Revises: 0f375c44e659 +Create Date: 2023-02-25 11:08:08.977217 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '374a670efe23' +down_revision = '0f375c44e659' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('app_config', schema=None) as batch_op: + batch_op.add_column(sa.Column('privacy_policy_date', sa.DateTime(), nullable=True)) + batch_op.add_column(sa.Column('privacy_policy', sa.Text(), nullable=True)) + batch_op.add_column(sa.Column('about', sa.Text(), nullable=True)) + + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('accepted_policy_date', sa.DateTime(), nullable=True)) + batch_op.alter_column('date_format', + existing_type=sa.VARCHAR(length=50), + nullable=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.alter_column('date_format', + existing_type=sa.VARCHAR(length=50), + nullable=False) + batch_op.drop_column('accepted_policy_date') + + with op.batch_alter_table('app_config', schema=None) as batch_op: + batch_op.drop_column('about') + batch_op.drop_column('privacy_policy') + batch_op.drop_column('privacy_policy_date') + + # ### end Alembic commands ### diff --git a/fittrackee/tests/application/test_app_config_api.py b/fittrackee/tests/application/test_app_config_api.py index 499cbe27..87892c19 100644 --- a/fittrackee/tests/application/test_app_config_api.py +++ b/fittrackee/tests/application/test_app_config_api.py @@ -1,5 +1,7 @@ import json +from datetime import datetime from typing import Optional +from unittest.mock import Mock, patch import pytest from flask import Flask @@ -296,7 +298,7 @@ class TestUpdateConfig(ApiTestCaseMixin): @pytest.mark.parametrize( 'input_description,input_email', [('input string', ''), ('None', None)] ) - def test_it_empties_error_if_admin_contact_is_an_empty( + def test_it_empties_contact_if_provided_admin_contact_is_an_empty( self, app: Flask, user_1_admin: User, @@ -325,6 +327,66 @@ class TestUpdateConfig(ApiTestCaseMixin): assert 'success' in data['status'] assert data['data']['admin_contact'] is None + def test_it_updates_about( + self, + app: Flask, + user_1_admin: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + about = self.random_string() + + response = client.patch( + '/api/config', + content_type='application/json', + data=json.dumps( + dict( + about=about, + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert data['data']['about'] == about + + def test_it_updates_privacy_policy( + self, + app: Flask, + user_1_admin: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + privacy_policy = self.random_string() + privacy_policy_date = datetime.utcnow() + + with patch( + 'fittrackee.application.app_config.datetime' + ) as datetime_mock: + datetime_mock.utcnow = Mock(return_value=privacy_policy_date) + response = client.patch( + '/api/config', + content_type='application/json', + data=json.dumps( + dict( + privacy_policy=privacy_policy, + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert data['data']['privacy_policy'] == privacy_policy + assert data['data'][ + 'privacy_policy_date' + ] == privacy_policy_date.strftime('%a, %d %b %Y %H:%M:%S GMT') + @pytest.mark.parametrize( 'client_scope, can_access', [ diff --git a/fittrackee/tests/application/test_app_config_model.py b/fittrackee/tests/application/test_app_config_model.py index 03d6be10..3363f94e 100644 --- a/fittrackee/tests/application/test_app_config_model.py +++ b/fittrackee/tests/application/test_app_config_model.py @@ -1,3 +1,5 @@ +from datetime import datetime + import pytest from flask import Flask @@ -5,6 +7,8 @@ from fittrackee import VERSION from fittrackee.application.models import AppConfig from fittrackee.users.models import User +from ..utils import random_string + class TestConfigModel: def test_application_config( @@ -88,3 +92,26 @@ class TestConfigModel: serialized_app_config['weather_provider'] == expected_weather_provider ) + + def test_it_returns_privacy_policy(self, app: Flask) -> None: + app_config = AppConfig.query.first() + privacy_policy = random_string() + privacy_policy_date = datetime.now() + app_config.privacy_policy = privacy_policy + app_config.privacy_policy_date = privacy_policy_date + + serialized_app_config = app_config.serialize() + + assert serialized_app_config["privacy_policy"] == privacy_policy + assert ( + serialized_app_config["privacy_policy_date"] == privacy_policy_date + ) + + def test_it_returns_about(self, app: Flask) -> None: + app_config = AppConfig.query.first() + about = random_string() + app_config.about = about + + serialized_app_config = app_config.serialize() + + assert serialized_app_config["about"] == about diff --git a/fittrackee/tests/fixtures/fixtures_users.py b/fittrackee/tests/fixtures/fixtures_users.py index 81b0e49c..1f3a9977 100644 --- a/fittrackee/tests/fixtures/fixtures_users.py +++ b/fittrackee/tests/fixtures/fixtures_users.py @@ -13,6 +13,7 @@ from ..utils import random_string def user_1() -> User: user = User(username='test', email='test@test.com', password='12345678') user.is_active = True + user.accepted_policy = datetime.datetime.utcnow() db.session.add(user) db.session.commit() return user @@ -22,6 +23,7 @@ def user_1() -> User: def user_1_upper() -> User: user = User(username='TEST', email='TEST@TEST.COM', password='12345678') user.is_active = True + user.accepted_policy = datetime.datetime.utcnow() db.session.add(user) db.session.commit() return user @@ -34,6 +36,7 @@ def user_1_admin() -> User: ) admin.admin = True admin.is_active = True + admin.accepted_policy = datetime.datetime.utcnow() db.session.add(admin) db.session.commit() return admin @@ -50,6 +53,7 @@ def user_1_full() -> User: user.timezone = 'America/New_York' user.birth_date = datetime.datetime.strptime('01/01/1980', '%d/%m/%Y') user.is_active = True + user.accepted_policy = datetime.datetime.utcnow() db.session.add(user) db.session.commit() return user @@ -60,6 +64,7 @@ def user_1_paris() -> User: user = User(username='test', email='test@test.com', password='12345678') user.timezone = 'Europe/Paris' user.is_active = True + user.accepted_policy = datetime.datetime.utcnow() db.session.add(user) db.session.commit() return user @@ -69,6 +74,7 @@ def user_1_paris() -> User: def user_2() -> User: user = User(username='toto', email='toto@toto.com', password='12345678') user.is_active = True + user.accepted_policy = datetime.datetime.utcnow() db.session.add(user) db.session.commit() return user @@ -79,6 +85,7 @@ def user_2_admin() -> User: user = User(username='toto', email='toto@toto.com', password='12345678') user.is_active = True user.admin = True + user.accepted_policy = datetime.datetime.utcnow() db.session.add(user) db.session.commit() return user @@ -89,6 +96,7 @@ def user_3() -> User: user = User(username='sam', email='sam@test.com', password='12345678') user.is_active = True user.weekm = True + user.accepted_policy = datetime.datetime.utcnow() db.session.add(user) db.session.commit() return user @@ -100,6 +108,7 @@ def inactive_user() -> User: username='inactive', email='inactive@example.com', password='12345678' ) user.confirmation_token = random_string() + user.accepted_policy = datetime.datetime.utcnow() db.session.add(user) db.session.commit() return user diff --git a/fittrackee/tests/users/test_auth_api.py b/fittrackee/tests/users/test_auth_api.py index c0a4d354..454cf346 100644 --- a/fittrackee/tests/users/test_auth_api.py +++ b/fittrackee/tests/users/test_auth_api.py @@ -33,6 +33,48 @@ class TestUserRegistration(ApiTestCaseMixin): self.assert_400(response) + def test_it_returns_error_if_accepted_policy_is_missing( + self, app: Flask + ) -> None: + client = app.test_client() + + response = client.post( + '/api/auth/register', + data=json.dumps( + dict( + username=self.random_string(), + email=self.random_email(), + password=self.random_string(), + ) + ), + content_type='application/json', + ) + + self.assert_400(response) + + def test_it_returns_error_if_accepted_policy_is_false( + self, app: Flask + ) -> None: + client = app.test_client() + + response = client.post( + '/api/auth/register', + data=json.dumps( + dict( + username=self.random_string(), + email=self.random_email(), + password=self.random_string(), + accepted_policy=False, + ) + ), + content_type='application/json', + ) + + self.assert_400( + response, + 'sorry, you must agree privacy policy to register', + ) + def test_it_returns_error_if_username_is_missing(self, app: Flask) -> None: client = app.test_client() @@ -42,6 +84,7 @@ class TestUserRegistration(ApiTestCaseMixin): dict( email=self.random_email(), password=self.random_string(), + accepted_policy=True, ) ), content_type='application/json', @@ -65,6 +108,7 @@ class TestUserRegistration(ApiTestCaseMixin): username=self.random_string(length=input_username_length), email=self.random_email(), password=self.random_string(), + accepted_policy=True, ) ), content_type='application/json', @@ -91,6 +135,7 @@ class TestUserRegistration(ApiTestCaseMixin): username=input_username, email=self.random_email(), password=self.random_email(), + accepted_policy=True, ) ), content_type='application/json', @@ -121,6 +166,7 @@ class TestUserRegistration(ApiTestCaseMixin): ), email=self.random_email(), password=self.random_string(), + accepted_policy=True, ) ), content_type='application/json', @@ -137,6 +183,7 @@ class TestUserRegistration(ApiTestCaseMixin): dict( username=self.random_string(), email=self.random_email(), + accepted_policy=True, ) ), content_type='application/json', @@ -156,6 +203,7 @@ class TestUserRegistration(ApiTestCaseMixin): username=self.random_string(), email=self.random_email(), password=self.random_string(length=7), + accepted_policy=True, ) ), content_type='application/json', @@ -172,6 +220,7 @@ class TestUserRegistration(ApiTestCaseMixin): dict( username=self.random_string(), password=self.random_string(), + accepted_policy=True, ) ), content_type='application/json', @@ -189,6 +238,7 @@ class TestUserRegistration(ApiTestCaseMixin): username=self.random_string(), email=self.random_string(), password=self.random_string(), + accepted_policy=True, ) ), content_type='application/json', @@ -207,6 +257,7 @@ class TestUserRegistration(ApiTestCaseMixin): dict( username=self.random_string(), email=self.random_string(), + accepted_policy=True, ) ), content_type='application/json', @@ -224,6 +275,7 @@ class TestUserRegistration(ApiTestCaseMixin): username=self.random_string(), email=self.random_email(), password=self.random_string(), + accepted_policy=True, ) ), content_type='application/json', @@ -248,6 +300,7 @@ class TestUserRegistration(ApiTestCaseMixin): username=username, email=self.random_email(), password=self.random_string(), + accepted_policy=True, ) ), content_type='application/json', @@ -269,25 +322,30 @@ class TestUserRegistration(ApiTestCaseMixin): client = app.test_client() username = self.random_string() email = self.random_email() + accepted_policy_date = datetime.utcnow() - client.post( - '/api/auth/register', - data=json.dumps( - dict( - username=username, - email=email, - password=self.random_string(), - language=input_language, - ) - ), - content_type='application/json', - ) + with patch('fittrackee.users.auth.datetime.datetime') as datetime_mock: + datetime_mock.utcnow = Mock(return_value=accepted_policy_date) + client.post( + '/api/auth/register', + data=json.dumps( + dict( + username=username, + email=email, + password=self.random_string(), + language=input_language, + accepted_policy=True, + ) + ), + content_type='application/json', + ) new_user = User.query.filter_by(username=username).first() assert new_user.email == email assert new_user.password is not None assert new_user.is_active is False assert new_user.language == expected_language + assert new_user.accepted_policy_date == accepted_policy_date @pytest.mark.parametrize( 'input_language,expected_language', @@ -314,6 +372,7 @@ class TestUserRegistration(ApiTestCaseMixin): email=email, password='12345678', language=input_language, + accepted_policy=True, ) ), content_type='application/json', @@ -353,6 +412,7 @@ class TestUserRegistration(ApiTestCaseMixin): username=username, email=email, password='12345678', + accepted_policy=True, ) ), content_type='application/json', @@ -381,6 +441,7 @@ class TestUserRegistration(ApiTestCaseMixin): else user_1.email.lower() ), password=self.random_string(), + accepted_policy=True, ) ), content_type='application/json', @@ -404,6 +465,7 @@ class TestUserRegistration(ApiTestCaseMixin): username=self.random_string(), email=user_1.email, password=self.random_string(), + accepted_policy=True, ) ), content_type='application/json', @@ -1983,6 +2045,7 @@ class TestRegistrationConfiguration(ApiTestCaseMixin): username=self.random_string(), email=self.random_email(), password=self.random_string(), + accepted_policy=True, ) ), content_type='application/json', @@ -1995,6 +2058,7 @@ class TestRegistrationConfiguration(ApiTestCaseMixin): username=self.random_string(), email=self.random_email(), password=self.random_string(), + accepted_policy=True, ) ), content_type='application/json', @@ -2015,6 +2079,7 @@ class TestRegistrationConfiguration(ApiTestCaseMixin): username=self.random_string(), email=self.random_email(), password=self.random_string(), + accepted_policy=True, ) ), content_type='application/json', @@ -2027,6 +2092,7 @@ class TestRegistrationConfiguration(ApiTestCaseMixin): username=self.random_string(), email=self.random_email(), password=self.random_string(), + accepted_policy=True, ) ), content_type='application/json', diff --git a/fittrackee/tests/users/test_users_api.py b/fittrackee/tests/users/test_users_api.py index c80cf77d..d66008ea 100644 --- a/fittrackee/tests/users/test_users_api.py +++ b/fittrackee/tests/users/test_users_api.py @@ -1624,7 +1624,7 @@ class TestDeleteUser(ApiTestCaseMixin): 'you can not delete your account, no other user has admin rights', ) - def test_it_enables_registration_after_user_delete( + def test_it_enables_registration_after_user_delete_when_users_count_is_below_limit( # noqa self, app_with_3_users_max: Flask, user_1_admin: User, @@ -1646,6 +1646,7 @@ class TestDeleteUser(ApiTestCaseMixin): username=self.random_string(), email=self.random_email(), password=self.random_string(), + accepted_policy=True, ) ), content_type='application/json', @@ -1653,7 +1654,7 @@ class TestDeleteUser(ApiTestCaseMixin): assert response.status_code == 200 - def test_it_does_not_enable_registration_on_user_delete( + def test_it_does_not_enable_registration_on_user_delete_when_users_count_is_not_below_limit( # noqa self, app_with_3_users_max: Flask, user_1_admin: User, @@ -1677,6 +1678,7 @@ class TestDeleteUser(ApiTestCaseMixin): email='test@test.com', password='12345678', password_conf='12345678', + accepted_policy=True, ) ), content_type='application/json', diff --git a/fittrackee/tests/users/test_users_model.py b/fittrackee/tests/users/test_users_model.py index 4df8e45a..a5fb4c2b 100644 --- a/fittrackee/tests/users/test_users_model.py +++ b/fittrackee/tests/users/test_users_model.py @@ -78,6 +78,16 @@ class TestUserSerializeAsAuthUser(UserModelAssertMixin): self.assert_workouts_keys_are_present(serialized_user) + def test_it_returns_accepted_privacy_policy_date( + self, app: Flask, user_1: User + ) -> None: + serialized_user = user_1.serialize(user_1) + + assert ( + serialized_user['accepted_policy_date'] + == user_1.accepted_policy_date + ) + def test_it_does_not_return_confirmation_token( self, app: Flask, user_1_admin: User, user_2: User ) -> None: @@ -118,6 +128,13 @@ class TestUserSerializeAsAdmin(UserModelAssertMixin): self.assert_workouts_keys_are_present(serialized_user) + def test_it_does_not_return_accepted_privacy_policy_date( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + serialized_user = user_2.serialize(user_1_admin) + + assert 'accepted_policy_date' not in serialized_user + def test_it_does_not_return_confirmation_token( self, app: Flask, user_1_admin: User, user_2: User ) -> None: diff --git a/fittrackee/users/auth.py b/fittrackee/users/auth.py index 61abcbb9..3b641fb0 100644 --- a/fittrackee/users/auth.py +++ b/fittrackee/users/auth.py @@ -115,6 +115,7 @@ def register_user() -> Union[Tuple[Dict, int], HttpResponse]: : Union[Tuple[Dict, int], HttpResponse]: or post_data.get('username') is None or post_data.get('email') is None or post_data.get('password') is None + or post_data.get('accepted_policy') is None ): return InvalidPayloadErrorResponse() + + accepted_policy = post_data.get('accepted_policy') is True + if not accepted_policy: + return InvalidPayloadErrorResponse( + 'sorry, you must agree privacy policy to register' + ) + username = post_data.get('username') email = post_data.get('email') password = post_data.get('password') @@ -176,6 +185,7 @@ def register_user() -> Union[Tuple[Dict, int], HttpResponse]: new_user.date_format = 'MM/dd/yyyy' new_user.confirmation_token = secrets.token_urlsafe(30) new_user.language = language + new_user.accepted_policy_date = datetime.datetime.utcnow() db.session.add(new_user) db.session.commit() @@ -288,6 +298,7 @@ def get_authenticated_user_profile( { "data": { + "accepted_privacy_policy": "Sat, 25 Fev 2023 13:52:58 GMT", "admin": false, "bio": null, "birth_date": null, diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index b5585a6e..228cf841 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -52,6 +52,7 @@ class User(BaseModel): email_to_confirm = db.Column(db.String(255), nullable=True) confirmation_token = db.Column(db.String(255), nullable=True) display_ascent = db.Column(db.Boolean, default=True, nullable=False) + accepted_policy_date = db.Column(db.DateTime, nullable=True) def __repr__(self) -> str: return f'' @@ -191,6 +192,7 @@ class User(BaseModel): serialized_user = { **serialized_user, **{ + 'accepted_policy_date': self.accepted_policy_date, 'date_format': self.date_format, 'display_ascent': self.display_ascent, 'imperial_units': self.imperial_units,