From 4e3d2f98cf7100a841dad593c60336496f696afc Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 25 Feb 2023 14:06:49 +0100 Subject: [PATCH 01/23] 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, From f37cecec78d678107efd8e80102cfec8011d8e1d Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 26 Feb 2023 09:02:18 +0100 Subject: [PATCH 02/23] API - remove privacy policy and about text when empty string provided --- fittrackee/application/app_config.py | 11 +++- .../tests/application/test_app_config_api.py | 63 ++++++++++++++++--- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/fittrackee/application/app_config.py b/fittrackee/application/app_config.py index b3e480c9..b9da945c 100644 --- a/fittrackee/application/app_config.py +++ b/fittrackee/application/app_config.py @@ -161,10 +161,15 @@ def update_application_config(auth_user: User) -> Union[Dict, HttpResponse]: 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') + config.about = ( + config_data.get('about') if config_data.get('about') else None + ) if 'privacy_policy' in config_data: - config.privacy_policy = config_data.get('privacy_policy') - config.privacy_policy_date = datetime.utcnow() + privacy_policy = config_data.get('privacy_policy') + config.privacy_policy = privacy_policy if privacy_policy else None + config.privacy_policy_date = ( + datetime.utcnow() if privacy_policy else None + ) if config.max_zip_file_size < config.max_single_file_size: return InvalidPayloadErrorResponse( diff --git a/fittrackee/tests/application/test_app_config_api.py b/fittrackee/tests/application/test_app_config_api.py index 87892c19..bcf74acb 100644 --- a/fittrackee/tests/application/test_app_config_api.py +++ b/fittrackee/tests/application/test_app_config_api.py @@ -6,6 +6,7 @@ from unittest.mock import Mock, patch import pytest from flask import Flask +from fittrackee import db from fittrackee.application.models import AppConfig from fittrackee.users.models import User @@ -340,11 +341,7 @@ class TestUpdateConfig(ApiTestCaseMixin): response = client.patch( '/api/config', content_type='application/json', - data=json.dumps( - dict( - about=about, - ) - ), + data=json.dumps(dict(about=about)), headers=dict(Authorization=f'Bearer {auth_token}'), ) @@ -353,6 +350,28 @@ class TestUpdateConfig(ApiTestCaseMixin): assert 'success' in data['status'] assert data['data']['about'] == about + def test_it_empties_about_text_when_text_is_an_empty_string( + self, app: Flask, user_1_admin: User + ) -> None: + app_config = AppConfig.query.first() + app_config.about = self.random_string() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.patch( + '/api/config', + content_type='application/json', + data=json.dumps(dict(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'] is None + def test_it_updates_privacy_policy( self, app: Flask, @@ -371,11 +390,7 @@ class TestUpdateConfig(ApiTestCaseMixin): response = client.patch( '/api/config', content_type='application/json', - data=json.dumps( - dict( - privacy_policy=privacy_policy, - ) - ), + data=json.dumps(dict(privacy_policy=privacy_policy)), headers=dict(Authorization=f'Bearer {auth_token}'), ) @@ -387,6 +402,34 @@ class TestUpdateConfig(ApiTestCaseMixin): 'privacy_policy_date' ] == privacy_policy_date.strftime('%a, %d %b %Y %H:%M:%S GMT') + @pytest.mark.parametrize('input_privacy_policy', ['', None]) + def test_it_empties_privacy_policy_date_when_no_privacy_policy( + self, + app: Flask, + user_1_admin: User, + input_privacy_policy: Optional[str], + ) -> None: + app_config = AppConfig.query.first() + app_config.privacy_policy = self.random_string() + app_config.privacy_policy_date = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.patch( + '/api/config', + content_type='application/json', + data=json.dumps(dict(privacy_policy=input_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'] is None + assert data['data']['privacy_policy_date'] is None + @pytest.mark.parametrize( 'client_scope, can_access', [ From 1c1d2a77b7be628402ab678b22f7aae0b929cbeb Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 26 Feb 2023 09:07:02 +0100 Subject: [PATCH 03/23] API - it returns if user accepted to last privacy policy --- fittrackee/application/utils.py | 1 + fittrackee/tests/users/test_users_model.py | 42 ++++++++++++++++++---- fittrackee/users/models.py | 10 +++++- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/fittrackee/application/utils.py b/fittrackee/application/utils.py index eb8183fc..77aeab15 100644 --- a/fittrackee/application/utils.py +++ b/fittrackee/application/utils.py @@ -35,6 +35,7 @@ def update_app_config_from_database( current_app.config[ 'is_registration_enabled' ] = db_config.is_registration_enabled + current_app.config['privacy_policy_date'] = db_config.privacy_policy_date def verify_app_config(config_data: Dict) -> List: diff --git a/fittrackee/tests/users/test_users_model.py b/fittrackee/tests/users/test_users_model.py index a5fb4c2b..a2fe1a34 100644 --- a/fittrackee/tests/users/test_users_model.py +++ b/fittrackee/tests/users/test_users_model.py @@ -78,15 +78,45 @@ class TestUserSerializeAsAuthUser(UserModelAssertMixin): self.assert_workouts_keys_are_present(serialized_user) - def test_it_returns_accepted_privacy_policy_date( + def test_it_returns_user_did_not_accept_default_privacy_policy( self, app: Flask, user_1: User ) -> None: + # default privacy policy + app.config['privacy_policy_date'] = None + user_1.accepted_policy_date = None serialized_user = user_1.serialize(user_1) - assert ( - serialized_user['accepted_policy_date'] - == user_1.accepted_policy_date - ) + assert serialized_user['accepted_privacy_policy'] is False + + def test_it_returns_user_did_accept_default_privacy_policy( + self, app: Flask, user_1: User + ) -> None: + # default privacy policy + app.config['privacy_policy_date'] = None + user_1.accepted_policy_date = datetime.utcnow() + serialized_user = user_1.serialize(user_1) + + assert serialized_user['accepted_privacy_policy'] is True + + def test_it_returns_user_did_not_accept_last_policy( + self, app: Flask, user_1: User + ) -> None: + user_1.accepted_policy_date = datetime.utcnow() + # custom privacy policy + app.config['privacy_policy_date'] = datetime.utcnow() + serialized_user = user_1.serialize(user_1) + + assert serialized_user['accepted_privacy_policy'] is False + + def test_it_returns_user_did_accept_last_policy( + self, app: Flask, user_1: User + ) -> None: + # custom privacy policy + app.config['privacy_policy_date'] = datetime.utcnow() + user_1.accepted_policy_date = datetime.utcnow() + serialized_user = user_1.serialize(user_1) + + assert serialized_user['accepted_privacy_policy'] is True def test_it_does_not_return_confirmation_token( self, app: Flask, user_1_admin: User, user_2: User @@ -133,7 +163,7 @@ class TestUserSerializeAsAdmin(UserModelAssertMixin): ) -> None: serialized_user = user_2.serialize(user_1_admin) - assert 'accepted_policy_date' not in serialized_user + assert 'accepted_privacy_policy' not in serialized_user def test_it_does_not_return_confirmation_token( self, app: Flask, user_1_admin: User, user_2: User diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index 228cf841..1a536dde 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -189,10 +189,18 @@ class User(BaseModel): 'username': self.username, } if role == UserRole.AUTH_USER: + accepted_privacy_policy = False + if self.accepted_policy_date: + accepted_privacy_policy = ( + True + if current_app.config['privacy_policy_date'] is None + else current_app.config['privacy_policy_date'] + < self.accepted_policy_date + ) serialized_user = { **serialized_user, **{ - 'accepted_policy_date': self.accepted_policy_date, + 'accepted_privacy_policy': accepted_privacy_policy, 'date_format': self.date_format, 'display_ascent': self.display_ascent, 'imperial_units': self.imperial_units, From 8a3f9a5d59bd6ee435d99e4ec61457d9d1132264 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 26 Feb 2023 10:47:48 +0100 Subject: [PATCH 04/23] API - add route to accept privacy policy --- fittrackee/tests/users/test_auth_api.py | 75 ++++++++++++++++++++++++- fittrackee/users/auth.py | 53 +++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/fittrackee/tests/users/test_auth_api.py b/fittrackee/tests/users/test_auth_api.py index 454cf346..05c529e8 100644 --- a/fittrackee/tests/users/test_auth_api.py +++ b/fittrackee/tests/users/test_auth_api.py @@ -1,7 +1,7 @@ import json from datetime import datetime, timedelta from io import BytesIO -from typing import Optional +from typing import Optional, Union from unittest.mock import MagicMock, Mock, patch import pytest @@ -2749,3 +2749,76 @@ class TestUserLogout(ApiTestCaseMixin): ) self.assert_invalid_token(response) + + +class TestUserPrivacyPolicyUpdate(ApiTestCaseMixin): + def test_it_returns_error_if_user_is_not_authenticated( + self, app: Flask, user_1: User + ) -> None: + client = app.test_client() + + response = client.post( + '/api/auth/profile/edit/preferences', + content_type='application/json', + data=json.dumps(dict(accepted_policy=True)), + ) + + self.assert_401(response) + + def test_it_returns_error_if_accepted_policy_is_missing( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + '/api/auth/account/privacy-policy', + content_type='application/json', + data=json.dumps(dict()), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400(response) + + def test_it_updates_accepted_policy( + self, + app: Flask, + user_1: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + accepted_policy_date = datetime.utcnow() + + with patch('fittrackee.users.auth.datetime.datetime') as datetime_mock: + datetime_mock.utcnow = Mock(return_value=accepted_policy_date) + response = client.post( + '/api/auth/account/privacy-policy', + content_type='application/json', + data=json.dumps(dict(accepted_policy=True)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + assert user_1.accepted_policy_date == accepted_policy_date + + @pytest.mark.parametrize('input_accepted_policy', [False, '', None, 'foo']) + def test_it_return_error_if_user_has_not_accepted_policy( + self, + app: Flask, + user_1: User, + input_accepted_policy: Union[str, bool, None], + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + '/api/auth/account/privacy-policy', + content_type='application/json', + data=json.dumps(dict(accepted_policy=input_accepted_policy)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 400 diff --git a/fittrackee/users/auth.py b/fittrackee/users/auth.py index 3b641fb0..7e3a4064 100644 --- a/fittrackee/users/auth.py +++ b/fittrackee/users/auth.py @@ -883,6 +883,7 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]: : Union[Tuple[Dict, int], HttpResponse]: 'status': 'success', 'message': 'successfully logged out', }, 200 + + +@auth_blueprint.route('/auth/account/privacy-policy', methods=['POST']) +@require_auth() +def accept_privacy_policy(auth_user: User) -> Union[Dict, HttpResponse]: + """ + The authenticated user accepts the privacy policy. + + **Example request**: + + .. sourcecode:: http + + POST /auth/account/privacy-policy HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "status": "success" + } + + : Date: Sun, 26 Feb 2023 18:25:25 +0100 Subject: [PATCH 05/23] Client - init privacy policy and instance description --- fittrackee_client/package.json | 1 + fittrackee_client/src/components/About.vue | 14 +++ .../Administration/AdminApplication.vue | 81 +++++++++++++++-- fittrackee_client/src/components/Footer.vue | 18 ++-- .../src/components/PrivacyPolicy.vue | 91 +++++++++++++++++++ .../src/components/User/UserAuthForm.vue | 29 ++++++ .../src/locales/de/administration.json | 2 +- fittrackee_client/src/locales/de/de.ts | 2 + .../src/locales/de/privacy_policy.json | 1 + fittrackee_client/src/locales/en/about.json | 1 + .../src/locales/en/administration.json | 8 +- fittrackee_client/src/locales/en/en.ts | 2 + .../src/locales/en/privacy_policy.json | 38 ++++++++ fittrackee_client/src/locales/en/user.json | 1 + fittrackee_client/src/locales/fr/about.json | 1 + .../src/locales/fr/administration.json | 8 +- fittrackee_client/src/locales/fr/fr.ts | 2 + .../src/locales/fr/privacy_policy.json | 38 ++++++++ fittrackee_client/src/locales/fr/user.json | 1 + .../src/locales/it/administration.json | 2 +- fittrackee_client/src/locales/it/it.ts | 2 + .../src/locales/it/privacy_policy.json | 1 + fittrackee_client/src/locales/nb/nb.ts | 2 + .../src/locales/nb/privacy_policy.json | 1 + .../src/locales/nl/administration.json | 2 +- fittrackee_client/src/locales/nl/nl.ts | 2 + .../src/locales/nl/privacy_policy.json | 1 + fittrackee_client/src/router/index.ts | 8 +- fittrackee_client/src/types/application.ts | 5 + fittrackee_client/src/types/user.ts | 1 + fittrackee_client/src/utils/dates.ts | 2 +- .../src/views/PrivacyPolicyView.vue | 12 +++ fittrackee_client/yarn.lock | 5 + 33 files changed, 359 insertions(+), 26 deletions(-) create mode 100644 fittrackee_client/src/components/PrivacyPolicy.vue create mode 100644 fittrackee_client/src/locales/de/privacy_policy.json create mode 100644 fittrackee_client/src/locales/en/privacy_policy.json create mode 100644 fittrackee_client/src/locales/fr/privacy_policy.json create mode 100644 fittrackee_client/src/locales/it/privacy_policy.json create mode 100644 fittrackee_client/src/locales/nb/privacy_policy.json create mode 100644 fittrackee_client/src/locales/nl/privacy_policy.json create mode 100644 fittrackee_client/src/views/PrivacyPolicyView.vue diff --git a/fittrackee_client/package.json b/fittrackee_client/package.json index cda18eff..cc572836 100644 --- a/fittrackee_client/package.json +++ b/fittrackee_client/package.json @@ -30,6 +30,7 @@ "linkifyjs": "^4.0.2", "register-service-worker": "^1.7.1", "sanitize-html": "^2.10.0", + "snarkdown": "^2.0.0", "vue": "^3.2.45", "vue-chart-3": "3.1.1", "vue-fullscreen": "^3.1.1", diff --git a/fittrackee_client/src/components/About.vue b/fittrackee_client/src/components/About.vue index f497a785..7c5823f5 100644 --- a/fittrackee_client/src/components/About.vue +++ b/fittrackee_client/src/components/About.vue @@ -46,16 +46,24 @@ {{ weather_provider.name }} + + + diff --git a/fittrackee_client/src/components/User/UserAuthForm.vue b/fittrackee_client/src/components/User/UserAuthForm.vue index 1625c936..b3cda59e 100644 --- a/fittrackee_client/src/components/User/UserAuthForm.vue +++ b/fittrackee_client/src/components/User/UserAuthForm.vue @@ -98,6 +98,27 @@ @updatePassword="updatePassword" @passwordError="invalidateForm" /> +