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,