API - init user account activation

This commit is contained in:
Sam 2022-03-19 22:02:06 +01:00
parent b5b4ac8f92
commit a1f80e9745
26 changed files with 1334 additions and 67 deletions

View File

@ -41,3 +41,13 @@ def password_change_email(user: Dict, email_data: Dict) -> None:
recipient=user['email'], recipient=user['email'],
data=email_data, data=email_data,
) )
@dramatiq.actor(queue_name='fittrackee_emails')
def account_confirmation_email(user: Dict, email_data: Dict) -> None:
email_service.send(
template='account_confirmation',
lang=user['language'],
recipient=user['email'],
data=email_data,
)

View File

@ -0,0 +1,266 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" content=""/>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>FitTrackee - Confirm your account</title>
<style type="text/css" rel="stylesheet" media="all">
body {
background-color: #F4F4F7;
color: #51545E;
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
a {
color: #3869D4;
}
a img {
border: none;
}
td {
word-break: break-word;
}
.preheader {
display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
}
body,
td,
th {
font-family: Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
font-size: 22px;
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p {
color: #51545E;
margin: .4em 0 1.1875em;
font-size: 16px;
line-height: 1.625;
}
p.sub {
color: #6B6E76;
font-size: 13px;
}
.button {
background-color: #3869D4;
border-top: 10px solid #3869D4;
border-right: 18px solid #3869D4;
border-bottom: 10px solid #3869D4;
border-left: 18px solid #3869D4;
display: inline-block;
color: #FFF;
text-decoration: none;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
border-right: 18px solid #22BC66;
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
text-align: center !important;
}
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
background-color: #F4F4F7;
}
.email-content {
width: 100%;
margin: 0;
padding: 0;
}
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead-name {
font-size: 16px;
font-weight: bold;
color: #A8AAAF;
text-decoration: none;
text-shadow: 0 1px 0 white;
}
.email-body {
width: 100%;
margin: 0;
padding: 0;
background-color: #FFFFFF;
}
.email-body-inner {
width: 570px;
margin: 0 auto;
padding: 0;
background-color: #FFFFFF;
}
.body-action {
width: 100%;
margin: 30px auto;
padding: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #EAEAEC;
}
.content-cell {
padding: 35px;
}
@media only screen and (max-width: 600px) {
.email-body-inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body,
.email-body,
.email-body-inner,
.email-content,
.email-wrapper,
.email-masthead,
.email-footer {
background-color: #333333 !important;
color: #FFF !important;
}
p,
h1 {
color: #FFF !important;
}
.email-masthead-name {
text-shadow: none !important;
}
}
</style>
<!--[if mso]>
<style type="text/css">
.f-fallback {
font-family: Arial, sans-serif;
}
</style>
<![endif]-->
</head>
<body>
<span class="preheader">Use this link to confirm your account.</span>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="email-masthead">
<a href="{{fittrackee_url}}" class="f-fallback email-masthead-name">
FitTrackee
</a>
</td>
</tr>
<tr>
<td class="email-body" width="100%" cellpadding="0" cellspacing="0">
<table class="email-body-inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell">
<div class="f-fallback">
<h1>Hi {{username}},</h1>
<p>You have created an account on FitTrackee account. Use the link below to confirm your address email.</p>
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center">
<a href="{{account_confirmation_url}}" class="f-fallback button button--green" target="_blank">Verify your email</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>
{% if operating_system and browser_name %}For security, this request was received from a {{operating_system}} device using {{browser_name}}.
{% endif %}If this account creation wasn't initiated by you, please ignore this email.
</p>
<p>Thanks,
<br>The FitTrackee Team</p>
<table class="body-sub" role="presentation">
<tr>
<td>
<p class="f-fallback sub">If youre having trouble with the button above, copy and paste the URL below into your web browser.</p>
<p class="f-fallback sub">{{account_confirmation_url}}</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
<p class="f-fallback sub align-center">&copy; FitTrackee.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,12 @@
Hi {{username}},
You have created an account on FitTrackee account. Use the link below to confirm your address email.
Verify your email: {{ account_confirmation_url }}
{% if operating_system and browser_name %}For security, this request was received from a {{operating_system}} device using {{browser_name}}.
{% endif %}If this account creation wasn't initiated by you, please ignore this email.
Thanks,
The FitTrackee Team
{{fittrackee_url}}

View File

@ -0,0 +1 @@
FitTrackee - Confirm your account

View File

@ -0,0 +1,268 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" content=""/>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>FitTrackee - Confirmer votre inscription</title>
<style type="text/css" rel="stylesheet" media="all">
body {
background-color: #F4F4F7;
color: #51545E;
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
a {
color: #3869D4;
}
a img {
border: none;
}
td {
word-break: break-word;
}
.preheader {
display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
}
body,
td,
th {
font-family: Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
font-size: 22px;
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p {
color: #51545E;
margin: .4em 0 1.1875em;
font-size: 16px;
line-height: 1.625;
}
p.sub {
color: #6B6E76;
font-size: 13px;
}
.button {
background-color: #3869D4;
border-top: 10px solid #3869D4;
border-right: 18px solid #3869D4;
border-bottom: 10px solid #3869D4;
border-left: 18px solid #3869D4;
display: inline-block;
color: #FFF;
text-decoration: none;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
border-right: 18px solid #22BC66;
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
text-align: center !important;
}
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
background-color: #F4F4F7;
}
.email-content {
width: 100%;
margin: 0;
padding: 0;
}
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead-name {
font-size: 16px;
font-weight: bold;
color: #A8AAAF;
text-decoration: none;
text-shadow: 0 1px 0 white;
}
.email-body {
width: 100%;
margin: 0;
padding: 0;
background-color: #FFFFFF;
}
.email-body-inner {
width: 570px;
margin: 0 auto;
padding: 0;
background-color: #FFFFFF;
}
.body-action {
width: 100%;
margin: 30px auto;
padding: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #EAEAEC;
}
.content-cell {
padding: 35px;
}
@media only screen and (max-width: 600px) {
.email-body-inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body,
.email-body,
.email-body-inner,
.email-content,
.email-wrapper,
.email-masthead,
.email-footer {
background-color: #333333 !important;
color: #FFF !important;
}
p,
h1 {
color: #FFF !important;
}
.email-masthead-name {
text-shadow: none !important;
}
}
</style>
<!--[if mso]>
<style type="text/css">
.f-fallback {
font-family: Arial, sans-serif;
}
</style>
<![endif]-->
</head>
<body>
<span class="preheader">Utiliser ce lien pour confirmer votre inscription.</span>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="email-masthead">
<a href="{{fittrackee_url}}" class="f-fallback email-masthead-name">
FitTrackee
</a>
</td>
</tr>
<tr>
<td class="email-body" width="100%" cellpadding="0" cellspacing="0">
<table class="email-body-inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell">
<div class="f-fallback">
<h1>Bonjour {{username}},</h1>
<p>Vous avez créé un sur FitTrackee.
Cliquez sur le lien ci-dessous pour confirmer votre adresse email.
</p>
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center">
<a href="{{account_confirmation_url}}" class="f-fallback button button--green" target="_blank">Vérifier l'adresse email</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>
{% if operating_system and browser_name %}Pour vérification, cette demande a été reçue à partir d'un appareil sous {{operating_system}}, utilisant le navigateur {{browser_name}}.
{% endif %}Si vous n'êtes pas à l'origine de la création de ce compte, vous pouvez ignorer cet e-mail.
</p>
<p>Merci,
<br>L'équipe FitTrackee</p>
<table class="body-sub" role="presentation">
<tr>
<td>
<p class="f-fallback sub">Si vous avez des problèmes avec le bouton, vous pouvez copier et coller le lien suivant dans votre navigateur</p>
<p class="f-fallback sub">{{account_confirmation_url}}</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
<p class="f-fallback sub align-center">&copy; FitTrackee.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,13 @@
Bonjour {{username}},
Vous avez créé un sur FitTrackee.
Cliquez sur le lien ci-dessous pour confirmer votre adresse email.
Vérifier l'adresse email : {{ account_confirmation_url }}
{% if operating_system and browser_name %}Pour vérification, cette demande a été reçue à partir d'un appareil sous {{operating_system}}, utilisant le navigateur {{browser_name}}.
{% endif %}Si vous n'êtes pas à l'origine de la création de ce compte, vous pouvez ignorer cet e-mail.
Merci,
L'équipe FitTrackee
{{fittrackee_url}}

View File

@ -0,0 +1 @@
FitTrackee - Confirmer votre inscription

View File

@ -25,6 +25,11 @@ def upgrade():
'users', 'email', existing_type=sa.String(length=120), 'users', 'email', existing_type=sa.String(length=120),
type_=sa.String(length=255), existing_nullable=False type_=sa.String(length=255), existing_nullable=False
) )
op.add_column(
'users',
sa.Column('is_active', sa.Boolean(), default=False, nullable=True))
op.execute("UPDATE users SET is_active = true")
op.alter_column('users', 'is_active', nullable=False)
op.add_column( op.add_column(
'users', 'users',
sa.Column('email_to_confirm', sa.String(length=255), nullable=True)) sa.Column('email_to_confirm', sa.String(length=255), nullable=True))
@ -36,6 +41,7 @@ def upgrade():
def downgrade(): def downgrade():
op.drop_column('users', 'confirmation_token') op.drop_column('users', 'confirmation_token')
op.drop_column('users', 'email_to_confirm') op.drop_column('users', 'email_to_confirm')
op.drop_column('users', 'is_active')
op.alter_column( op.alter_column(
'users', 'email', existing_type=sa.String(length=255), 'users', 'email', existing_type=sa.String(length=255),
type_=sa.String(length=120), existing_nullable=False type_=sa.String(length=120), existing_nullable=False

View File

@ -9,17 +9,12 @@ from ..mixins import ApiTestCaseMixin
class TestGetConfig(ApiTestCaseMixin): class TestGetConfig(ApiTestCaseMixin):
def test_it_gets_application_config( def test_it_gets_application_config_for_unauthenticated_user(
self, app: Flask, user_1: User self, app: Flask
) -> None: ) -> None:
client, auth_token = self.get_test_client_and_auth_token( client = app.test_client()
app, user_1.email
)
response = client.get( response = client.get('/api/config')
'/api/config',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode()) data = json.loads(response.data.decode())
assert response.status_code == 200 assert response.status_code == 200
@ -36,6 +31,22 @@ class TestGetConfig(ApiTestCaseMixin):
) )
assert data['data']['version'] == fittrackee.__version__ assert data['data']['version'] == fittrackee.__version__
def test_it_gets_application_config(
self, app: Flask, user_1: User
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
response = client.get(
'/api/config',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
def test_it_returns_error_if_application_has_no_config( def test_it_returns_error_if_application_has_no_config(
self, app_no_config: Flask, user_1_admin: User self, app_no_config: Flask, user_1_admin: User
) -> None: ) -> None:

View File

@ -0,0 +1,174 @@
# flake8: noqa
expected_en_text_body = """Hi test,
You have created an account on FitTrackee account. Use the link below to confirm your address email.
Verify your email: http://localhost/account-confirmation?token=xxx
For security, this request was received from a Linux device using Firefox.
If this account creation wasn't initiated by you, please ignore this email.
Thanks,
The FitTrackee Team
http://localhost"""
expected_fr_text_body = """Bonjour test,
Vous avez créé un sur FitTrackee.
Cliquez sur le lien ci-dessous pour confirmer votre adresse email.
Vérifier l'adresse email : http://localhost/account-confirmation?token=xxx
Pour vérification, cette demande a été reçue à partir d'un appareil sous Linux, utilisant le navigateur Firefox.
Si vous n'êtes pas à l'origine de la création de ce compte, vous pouvez ignorer cet e-mail.
Merci,
L'équipe FitTrackee
http://localhost"""
expected_en_html_body = """ <body>
<span class="preheader">Use this link to confirm your account.</span>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="email-masthead">
<a href="http://localhost" class="f-fallback email-masthead-name">
FitTrackee
</a>
</td>
</tr>
<tr>
<td class="email-body" width="100%" cellpadding="0" cellspacing="0">
<table class="email-body-inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell">
<div class="f-fallback">
<h1>Hi test,</h1>
<p>You have created an account on FitTrackee account. Use the link below to confirm your address email.</p>
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center">
<a href="http://localhost/account-confirmation?token=xxx" class="f-fallback button button--green" target="_blank">Verify your email</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>
For security, this request was received from a Linux device using Firefox.
If this account creation wasn't initiated by you, please ignore this email.
</p>
<p>Thanks,
<br>The FitTrackee Team</p>
<table class="body-sub" role="presentation">
<tr>
<td>
<p class="f-fallback sub">If youre having trouble with the button above, copy and paste the URL below into your web browser.</p>
<p class="f-fallback sub">http://localhost/account-confirmation?token=xxx</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
<p class="f-fallback sub align-center">&copy; FitTrackee.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
expected_fr_html_body = """ <body>
<span class="preheader">Utiliser ce lien pour confirmer votre inscription.</span>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="email-masthead">
<a href="http://localhost" class="f-fallback email-masthead-name">
FitTrackee
</a>
</td>
</tr>
<tr>
<td class="email-body" width="100%" cellpadding="0" cellspacing="0">
<table class="email-body-inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell">
<div class="f-fallback">
<h1>Bonjour test,</h1>
<p>Vous avez créé un sur FitTrackee.
Cliquez sur le lien ci-dessous pour confirmer votre adresse email.
</p>
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center">
<a href="http://localhost/account-confirmation?token=xxx" class="f-fallback button button--green" target="_blank">Vérifier l'adresse email</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>
Pour vérification, cette demande a été reçue à partir d'un appareil sous Linux, utilisant le navigateur Firefox.
Si vous n'êtes pas à l'origine de la création de ce compte, vous pouvez ignorer cet e-mail.
</p>
<p>Merci,
<br>L'équipe FitTrackee</p>
<table class="body-sub" role="presentation">
<tr>
<td>
<p class="f-fallback sub">Si vous avez des problèmes avec le bouton, vous pouvez copier et coller le lien suivant dans votre navigateur</p>
<p class="f-fallback sub">http://localhost/account-confirmation?token=xxx</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
<p class="f-fallback sub align-center">&copy; FitTrackee.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""

View File

@ -0,0 +1,77 @@
import pytest
from flask import Flask
from fittrackee.emails.email import EmailTemplate
from .template_results.email_account_confirmation import (
expected_en_html_body,
expected_en_text_body,
expected_fr_html_body,
expected_fr_text_body,
)
class TestEmailTemplateForAccountConfirmation:
EMAIL_DATA = {
'username': 'test',
'account_confirmation_url': (
'http://localhost/account-confirmation?token=xxx'
),
'operating_system': 'Linux',
'browser_name': 'Firefox',
'fittrackee_url': 'http://localhost',
}
@pytest.mark.parametrize(
'lang, expected_subject',
[
('en', 'FitTrackee - Confirm your account'),
('fr', 'FitTrackee - Confirmer votre inscription'),
],
)
def test_it_gets_subject(
self, app: Flask, lang: str, expected_subject: str
) -> None:
email_template = EmailTemplate(app.config['TEMPLATES_FOLDER'])
subject = email_template.get_content(
'account_confirmation', lang, 'subject.txt', {}
)
assert subject == expected_subject
@pytest.mark.parametrize(
'lang, expected_text_body',
[
('en', expected_en_text_body),
('fr', expected_fr_text_body),
],
)
def test_it_gets_text_body(
self, app: Flask, lang: str, expected_text_body: str
) -> None:
email_template = EmailTemplate(app.config['TEMPLATES_FOLDER'])
text_body = email_template.get_content(
'account_confirmation', lang, 'body.txt', self.EMAIL_DATA
)
assert text_body == expected_text_body
def test_it_gets_en_html_body(self, app: Flask) -> None:
email_template = EmailTemplate(app.config['TEMPLATES_FOLDER'])
text_body = email_template.get_content(
'account_confirmation', 'en', 'body.html', self.EMAIL_DATA
)
assert expected_en_html_body in text_body
def test_it_gets_fr_html_body(self, app: Flask) -> None:
email_template = EmailTemplate(app.config['TEMPLATES_FOLDER'])
text_body = email_template.get_content(
'account_confirmation', 'fr', 'body.html', self.EMAIL_DATA
)
assert expected_fr_html_body in text_body

View File

@ -46,3 +46,9 @@ def user_reset_password_email() -> Iterator[MagicMock]:
def user_email_updated_to_new_address_mock() -> Iterator[MagicMock]: def user_email_updated_to_new_address_mock() -> Iterator[MagicMock]:
with patch('fittrackee.users.users.email_updated_to_new_address') as mock: with patch('fittrackee.users.users.email_updated_to_new_address') as mock:
yield mock yield mock
@pytest.fixture()
def account_confirmation_email_mock() -> Iterator[MagicMock]:
with patch('fittrackee.users.auth.account_confirmation_email') as mock:
yield mock

View File

@ -10,6 +10,7 @@ from fittrackee.workouts.models import Sport
@pytest.fixture() @pytest.fixture()
def user_1() -> User: def user_1() -> User:
user = User(username='test', email='test@test.com', password='12345678') user = User(username='test', email='test@test.com', password='12345678')
user.is_active = True
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
return user return user
@ -18,6 +19,7 @@ def user_1() -> User:
@pytest.fixture() @pytest.fixture()
def user_1_upper() -> User: def user_1_upper() -> User:
user = User(username='TEST', email='TEST@TEST.COM', password='12345678') user = User(username='TEST', email='TEST@TEST.COM', password='12345678')
user.is_active = True
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
return user return user
@ -29,6 +31,7 @@ def user_1_admin() -> User:
username='admin', email='admin@example.com', password='12345678' username='admin', email='admin@example.com', password='12345678'
) )
admin.admin = True admin.admin = True
admin.is_active = True
db.session.add(admin) db.session.add(admin)
db.session.commit() db.session.commit()
return admin return admin
@ -44,6 +47,7 @@ def user_1_full() -> User:
user.language = 'en' user.language = 'en'
user.timezone = 'America/New_York' user.timezone = 'America/New_York'
user.birth_date = datetime.datetime.strptime('01/01/1980', '%d/%m/%Y') user.birth_date = datetime.datetime.strptime('01/01/1980', '%d/%m/%Y')
user.is_active = True
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
return user return user
@ -53,6 +57,7 @@ def user_1_full() -> User:
def user_1_paris() -> User: def user_1_paris() -> User:
user = User(username='test', email='test@test.com', password='12345678') user = User(username='test', email='test@test.com', password='12345678')
user.timezone = 'Europe/Paris' user.timezone = 'Europe/Paris'
user.is_active = True
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
return user return user
@ -61,6 +66,7 @@ def user_1_paris() -> User:
@pytest.fixture() @pytest.fixture()
def user_2() -> User: def user_2() -> User:
user = User(username='toto', email='toto@toto.com', password='12345678') user = User(username='toto', email='toto@toto.com', password='12345678')
user.is_active = True
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
return user return user
@ -69,6 +75,7 @@ def user_2() -> User:
@pytest.fixture() @pytest.fixture()
def user_2_admin() -> User: def user_2_admin() -> User:
user = User(username='toto', email='toto@toto.com', password='12345678') user = User(username='toto', email='toto@toto.com', password='12345678')
user.is_active = True
user.admin = True user.admin = True
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
@ -78,12 +85,23 @@ def user_2_admin() -> User:
@pytest.fixture() @pytest.fixture()
def user_3() -> User: def user_3() -> User:
user = User(username='sam', email='sam@test.com', password='12345678') user = User(username='sam', email='sam@test.com', password='12345678')
user.is_active = True
user.weekm = True user.weekm = True
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
return user return user
@pytest.fixture()
def inactive_user() -> User:
user = User(
username='inactive', email='inactive@example.com', password='12345678'
)
db.session.add(user)
db.session.commit()
return user
@pytest.fixture() @pytest.fixture()
def user_sport_1_preference( def user_sport_1_preference(
user_1: User, sport_1_cycling: Sport user_1: User, sport_1_cycling: Sport

View File

@ -53,7 +53,10 @@ class ApiTestCaseMixin(RandomMixin):
) )
@staticmethod @staticmethod
def assert_401(response: TestResponse, error_message: str) -> Dict: def assert_401(
response: TestResponse,
error_message: Optional[str] = 'provide a valid auth token',
) -> Dict:
return assert_errored_response( return assert_errored_response(
response, 401, error_message=error_message response, 401, error_message=error_message
) )

View File

@ -123,7 +123,7 @@ class TestUserRegistration(ApiTestCaseMixin):
content_type='application/json', content_type='application/json',
) )
self.assert_400(response, 'sorry, that user already exists') self.assert_400(response, 'sorry, that username is already taken')
def test_it_returns_error_if_password_is_missing(self, app: Flask) -> None: def test_it_returns_error_if_password_is_missing(self, app: Flask) -> None:
client = app.test_client() client = app.test_client()
@ -193,11 +193,111 @@ class TestUserRegistration(ApiTestCaseMixin):
self.assert_400(response, 'email: valid email must be provided\n') self.assert_400(response, 'email: valid email must be provided\n')
def test_it_does_not_send_email_after_error(
self, app: Flask, account_confirmation_email_mock: Mock
) -> None:
client = app.test_client()
client.post(
'/api/auth/register',
data=json.dumps(
dict(
username=self.random_string(),
email=self.random_string(),
)
),
content_type='application/json',
)
account_confirmation_email_mock.send.assert_not_called()
def test_it_returns_success_if_payload_is_valid(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',
)
assert response.status_code == 200
assert response.content_type == 'application/json'
data = json.loads(response.data.decode())
assert data['status'] == 'success'
assert 'auth_token' not in data
def test_it_creates_user_with_inactive_account(self, app: Flask) -> None:
client = app.test_client()
username = self.random_string()
email = self.random_email()
client.post(
'/api/auth/register',
data=json.dumps(
dict(
username=username,
email=email,
password=self.random_string(),
)
),
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
def test_it_calls_account_confirmation_email_if_payload_is_valid(
self, app: Flask, account_confirmation_email_mock: Mock
) -> None:
client = app.test_client()
email = self.random_email()
username = self.random_string()
expected_token = self.random_string()
with patch('secrets.token_urlsafe', return_value=expected_token):
client.post(
'/api/auth/register',
data=json.dumps(
dict(
username=username,
email=email,
password='12345678',
)
),
content_type='application/json',
environ_base={'HTTP_USER_AGENT': USER_AGENT},
)
account_confirmation_email_mock.send.assert_called_once_with(
{
'language': 'en',
'email': email,
},
{
'username': username,
'fittrackee_url': 'http://0.0.0.0:5000',
'operating_system': 'linux',
'browser_name': 'firefox',
'account_confirmation_url': (
'http://0.0.0.0:5000/account-confirmation'
f'?token={expected_token}'
),
},
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
'text_transformation', 'text_transformation',
['upper', 'lower'], ['upper', 'lower'],
) )
def test_it_returns_error_if_user_already_exists_with_same_email( def test_it_does_not_return_error_if_a_user_already_exists_with_same_email(
self, app: Flask, user_1: User, text_transformation: str self, app: Flask, user_1: User, text_transformation: str
) -> None: ) -> None:
client = app.test_client() client = app.test_client()
@ -217,29 +317,30 @@ class TestUserRegistration(ApiTestCaseMixin):
content_type='application/json', content_type='application/json',
) )
self.assert_400(response, 'sorry, that user already exists') assert response.status_code == 200
assert response.content_type == 'application/json'
data = json.loads(response.data.decode())
assert data['status'] == 'success'
assert 'auth_token' not in data
def test_user_can_register(self, app: Flask) -> None: def test_it_does_not_call_account_confirmation_email_if_user_already_exists( # noqa
self, app: Flask, user_1: User, account_confirmation_email_mock: Mock
) -> None:
client = app.test_client() client = app.test_client()
response = client.post( client.post(
'/api/auth/register', '/api/auth/register',
data=json.dumps( data=json.dumps(
dict( dict(
username=self.random_string(), username=self.random_string(),
email=self.random_email(), email=user_1.email,
password=self.random_string(), password=self.random_string(),
) )
), ),
content_type='application/json', content_type='application/json',
) )
assert response.status_code == 201 account_confirmation_email_mock.send.assert_not_called()
assert response.content_type == 'application/json'
data = json.loads(response.data.decode())
assert data['status'] == 'success'
assert data['message'] == 'successfully registered'
assert data['auth_token']
class TestUserLogin(ApiTestCaseMixin): class TestUserLogin(ApiTestCaseMixin):
@ -269,6 +370,21 @@ class TestUserLogin(ApiTestCaseMixin):
self.assert_401(response, 'invalid credentials') self.assert_401(response, 'invalid credentials')
def test_it_returns_error_if_user_account_is_inactive(
self, app: Flask, inactive_user: User
) -> None:
client = app.test_client()
response = client.post(
'/api/auth/login',
data=json.dumps(
dict(email=inactive_user.email, password='12345678')
),
content_type='application/json',
)
self.assert_401(response, 'invalid credentials')
def test_it_returns_error_if_password_is_invalid( def test_it_returns_error_if_password_is_invalid(
self, app: Flask, user_1: User self, app: Flask, user_1: User
) -> None: ) -> None:
@ -1628,7 +1744,7 @@ class TestRegistrationConfiguration(ApiTestCaseMixin):
content_type='application/json', content_type='application/json',
) )
assert response.status_code == 201 assert response.status_code == 200
class TestPasswordResetRequest(ApiTestCaseMixin): class TestPasswordResetRequest(ApiTestCaseMixin):
@ -1974,3 +2090,47 @@ class TestEmailUpdateWitUnauthenticatedUser(ApiTestCaseMixin):
assert user_1.email == new_email assert user_1.email == new_email
assert user_1.email_to_confirm is None assert user_1.email_to_confirm is None
assert user_1.confirmation_token is None assert user_1.confirmation_token is None
class TestConfirmationAccount(ApiTestCaseMixin):
def test_it_returns_error_if_token_is_missing(self, app: Flask) -> None:
client = app.test_client()
response = client.post(
'/api/auth/account/confirm',
data=json.dumps(dict()),
content_type='application/json',
)
self.assert_400(response)
def test_it_returns_error_if_token_is_invalid(self, app: Flask) -> None:
client = app.test_client()
response = client.post(
'/api/auth/account/confirm',
data=json.dumps(dict(token=self.random_string())),
content_type='application/json',
)
self.assert_400(response)
def test_it_activates_user_account(
self, app: Flask, inactive_user: User
) -> None:
token = self.random_string()
inactive_user.confirmation_token = token
client = app.test_client()
response = client.post(
'/api/auth/account/confirm',
data=json.dumps(dict(token=token)),
content_type='application/json',
)
assert response.status_code == 200
data = json.loads(response.data.decode())
assert data['status'] == 'success'
assert data['message'] == 'account confirmation successful'
assert inactive_user.is_active is True
assert inactive_user.confirmation_token is None

View File

@ -28,6 +28,41 @@ class TestGetUser(ApiTestCaseMixin):
self.assert_403(response) self.assert_403(response)
def test_it_gets_inactive_user(
self, app: Flask, user_1_admin: User, inactive_user: User
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1_admin.email
)
response = client.get(
f'/api/users/{inactive_user.username}',
content_type='application/json',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert data['status'] == 'success'
assert len(data['data']['users']) == 1
user = data['data']['users'][0]
assert user['username'] == inactive_user.username
assert user['email'] == inactive_user.email
assert user['created_at']
assert not user['admin']
assert not user['is_active']
assert user['first_name'] is None
assert user['last_name'] is None
assert user['birth_date'] is None
assert user['bio'] is None
assert user['location'] is None
assert user['nb_sports'] == 0
assert user['nb_workouts'] == 0
assert user['records'] == []
assert user['sports_list'] == []
assert user['total_distance'] == 0
assert user['total_duration'] == '0:00:00'
def test_it_gets_single_user_without_workouts( def test_it_gets_single_user_without_workouts(
self, app: Flask, user_1_admin: User, user_2: User self, app: Flask, user_1_admin: User, user_2: User
) -> None: ) -> None:
@ -46,10 +81,11 @@ class TestGetUser(ApiTestCaseMixin):
assert data['status'] == 'success' assert data['status'] == 'success'
assert len(data['data']['users']) == 1 assert len(data['data']['users']) == 1
user = data['data']['users'][0] user = data['data']['users'][0]
assert user['username'] == 'toto' assert user['username'] == user_2.username
assert user['email'] == 'toto@toto.com' assert user['email'] == user_2.email
assert user['created_at'] assert user['created_at']
assert not user['admin'] assert not user['admin']
assert user['is_active']
assert user['first_name'] is None assert user['first_name'] is None
assert user['last_name'] is None assert user['last_name'] is None
assert user['birth_date'] is None assert user['birth_date'] is None
@ -87,10 +123,11 @@ class TestGetUser(ApiTestCaseMixin):
assert data['status'] == 'success' assert data['status'] == 'success'
assert len(data['data']['users']) == 1 assert len(data['data']['users']) == 1
user = data['data']['users'][0] user = data['data']['users'][0]
assert user['username'] == 'test' assert user['username'] == user_1.username
assert user['email'] == 'test@test.com' assert user['email'] == user_1.email
assert user['created_at'] assert user['created_at']
assert not user['admin'] assert not user['admin']
assert user['is_active']
assert user['first_name'] is None assert user['first_name'] is None
assert user['last_name'] is None assert user['last_name'] is None
assert user['birth_date'] is None assert user['birth_date'] is None
@ -134,8 +171,8 @@ class TestGetUsers(ApiTestCaseMixin):
self.assert_403(response) self.assert_403(response)
def test_it_get_users_list( def test_it_get_users_list_regardless_their_account_status(
self, app: Flask, user_1_admin: User, user_2: User, user_3: User self, app: Flask, user_1_admin: User, inactive_user: User, user_3: User
) -> None: ) -> None:
client, auth_token = self.get_test_client_and_auth_token( client, auth_token = self.get_test_client_and_auth_token(
app, user_1_admin.email app, user_1_admin.email
@ -154,11 +191,14 @@ class TestGetUsers(ApiTestCaseMixin):
assert 'created_at' in data['data']['users'][1] assert 'created_at' in data['data']['users'][1]
assert 'created_at' in data['data']['users'][2] assert 'created_at' in data['data']['users'][2]
assert 'admin' in data['data']['users'][0]['username'] assert 'admin' in data['data']['users'][0]['username']
assert 'toto' in data['data']['users'][1]['username'] assert 'inactive' in data['data']['users'][1]['username']
assert 'sam' in data['data']['users'][2]['username'] assert 'sam' in data['data']['users'][2]['username']
assert 'admin@example.com' in data['data']['users'][0]['email'] assert 'admin@example.com' in data['data']['users'][0]['email']
assert 'toto@toto.com' in data['data']['users'][1]['email'] assert 'inactive@example.com' in data['data']['users'][1]['email']
assert 'sam@test.com' in data['data']['users'][2]['email'] assert 'sam@test.com' in data['data']['users'][2]['email']
assert data['data']['users'][0]['is_active']
assert not data['data']['users'][1]['is_active']
assert data['data']['users'][2]['is_active']
assert data['data']['users'][0]['imperial_units'] is False assert data['data']['users'][0]['imperial_units'] is False
assert data['data']['users'][0]['timezone'] is None assert data['data']['users'][0]['timezone'] is None
assert data['data']['users'][0]['weekm'] is False assert data['data']['users'][0]['weekm'] is False
@ -223,6 +263,9 @@ class TestGetUsers(ApiTestCaseMixin):
assert 'admin@example.com' in data['data']['users'][0]['email'] assert 'admin@example.com' in data['data']['users'][0]['email']
assert 'toto@toto.com' in data['data']['users'][1]['email'] assert 'toto@toto.com' in data['data']['users'][1]['email']
assert 'sam@test.com' in data['data']['users'][2]['email'] assert 'sam@test.com' in data['data']['users'][2]['email']
assert data['data']['users'][0]['is_active']
assert data['data']['users'][1]['is_active']
assert data['data']['users'][2]['is_active']
assert data['data']['users'][0]['imperial_units'] is False assert data['data']['users'][0]['imperial_units'] is False
assert data['data']['users'][0]['timezone'] is None assert data['data']['users'][0]['timezone'] is None
assert data['data']['users'][0]['weekm'] is False assert data['data']['users'][0]['weekm'] is False
@ -1344,7 +1387,7 @@ class TestDeleteUser(ApiTestCaseMixin):
'you can not delete your account, no other user has admin rights', 'you can not delete your account, no other user has admin rights',
) )
def test_it_enables_registration_on_user_delete( def test_it_enables_registration_after_user_delete(
self, self,
app_with_3_users_max: Flask, app_with_3_users_max: Flask,
user_1_admin: User, user_1_admin: User,
@ -1363,15 +1406,15 @@ class TestDeleteUser(ApiTestCaseMixin):
'/api/auth/register', '/api/auth/register',
data=json.dumps( data=json.dumps(
dict( dict(
username='justatest', username=self.random_string(),
email='test@test.com', email=self.random_email(),
password='12345678', password=self.random_string(),
password_conf='12345678',
) )
), ),
content_type='application/json', content_type='application/json',
) )
assert response.status_code == 201
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(
self, self,

View File

@ -14,6 +14,7 @@ class TestUserModel:
assert 'created_at' in serialized_user assert 'created_at' in serialized_user
assert serialized_user['admin'] is False assert serialized_user['admin'] is False
assert serialized_user['first_name'] is None assert serialized_user['first_name'] is None
assert serialized_user['is_active']
assert serialized_user['last_name'] is None assert serialized_user['last_name'] is None
assert serialized_user['bio'] is None assert serialized_user['bio'] is None
assert serialized_user['location'] is None assert serialized_user['location'] is None
@ -99,6 +100,15 @@ class TestUserModel:
) )
assert serialized_user['records'][0]['workout_date'] assert serialized_user['records'][0]['workout_date']
def test_it_returns_is_active_to_false_fot_inactive_user(
self,
app: Flask,
inactive_user: User,
) -> None:
serialized_user = inactive_user.serialize(inactive_user)
assert serialized_user['is_active'] is False
class TestUserSportModel: class TestUserSportModel:
def test_user_model( def test_user_model(

View File

@ -887,3 +887,13 @@ class TestGetRecords(ApiTestCaseMixin):
assert workout_4_short_id == data['data']['records'][7]['workout_id'] assert workout_4_short_id == data['data']['records'][7]['workout_id']
assert 'MS' == data['data']['records'][7]['record_type'] assert 'MS' == data['data']['records'][7]['record_type']
assert 12.0 == data['data']['records'][7]['value'] assert 12.0 == data['data']['records'][7]['value']
def test_it_returns_error_if_user_is_not_authenticated(
self,
app: Flask,
) -> None:
client = app.test_client()
response = client.get('/api/records')
self.assert_401(response)

View File

@ -45,6 +45,16 @@ expected_sport_1_cycling_inactive_admin_result['has_workouts'] = False
class TestGetSports(ApiTestCaseMixin): class TestGetSports(ApiTestCaseMixin):
def test_it_returns_error_if_user_is_not_authenticated(
self,
app: Flask,
) -> None:
client = app.test_client()
response = client.get('/api/sports')
self.assert_401(response)
def test_it_gets_all_sports( def test_it_gets_all_sports(
self, self,
app: Flask, app: Flask,

View File

@ -9,6 +9,17 @@ from ..mixins import ApiTestCaseMixin
class TestGetStatsByTime(ApiTestCaseMixin): class TestGetStatsByTime(ApiTestCaseMixin):
def test_it_returns_error_if_user_is_not_authenticated(
self, app: Flask, user_1: User
) -> None:
client = app.test_client()
response = client.get(
f'/api/stats/{user_1.username}/by_time',
)
self.assert_401(response)
def test_it_gets_no_stats_when_user_has_no_workouts( def test_it_gets_no_stats_when_user_has_no_workouts(
self, app: Flask, user_1: User self, app: Flask, user_1: User
) -> None: ) -> None:
@ -853,6 +864,17 @@ class TestGetStatsByTime(ApiTestCaseMixin):
class TestGetStatsBySport(ApiTestCaseMixin): class TestGetStatsBySport(ApiTestCaseMixin):
def test_it_returns_error_if_user_is_not_authenticated(
self, app: Flask, user_1: User
) -> None:
client = app.test_client()
response = client.get(
f'/api/stats/{user_1.username}/by_sport',
)
self.assert_401(response)
def test_it_gets_stats_by_sport( def test_it_gets_stats_by_sport(
self, self,
app: Flask, app: Flask,
@ -987,6 +1009,15 @@ class TestGetStatsBySport(ApiTestCaseMixin):
class TestGetAllStats(ApiTestCaseMixin): class TestGetAllStats(ApiTestCaseMixin):
def test_it_returns_error_if_user_is_not_authenticated(
self, app: Flask, user_1: User
) -> None:
client = app.test_client()
response = client.get('/api/stats/all')
self.assert_401(response)
def test_it_returns_all_stats_when_users_have_no_workouts( def test_it_returns_all_stats_when_users_have_no_workouts(
self, app: Flask, user_1_admin: User, user_2: User self, app: Flask, user_1_admin: User, user_2: User
) -> None: ) -> None:

View File

@ -12,6 +12,15 @@ from .utils import get_random_short_id
class TestGetWorkouts(ApiTestCaseMixin): class TestGetWorkouts(ApiTestCaseMixin):
def test_it_returns_error_if_user_is_not_authenticated(
self, app: Flask
) -> None:
client = app.test_client()
response = client.get('/api/workouts')
self.assert_401(response)
def test_it_gets_all_workouts_for_authenticated_user( def test_it_gets_all_workouts_for_authenticated_user(
self, self,
app: Flask, app: Flask,

View File

@ -206,6 +206,22 @@ def assert_workout_data_wo_gpx(data: Dict) -> None:
class TestPostWorkoutWithGpx(ApiTestCaseMixin, CallArgsMixin): class TestPostWorkoutWithGpx(ApiTestCaseMixin, CallArgsMixin):
def test_it_returns_error_if_user_is_not_authenticated(
self, app: Flask, sport_1_cycling: Sport, gpx_file: str
) -> None:
client = app.test_client()
response = client.post(
'/api/workouts',
data=dict(
file=(BytesIO(str.encode(gpx_file)), 'example.gpx'),
data='{"sport_id": 1}',
),
headers=dict(content_type='multipart/form-data'),
)
self.assert_401(response)
def test_it_adds_an_workout_with_gpx_file( def test_it_adds_an_workout_with_gpx_file(
self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str
) -> None: ) -> None:
@ -569,6 +585,27 @@ class TestPostWorkoutWithGpx(ApiTestCaseMixin, CallArgsMixin):
class TestPostWorkoutWithoutGpx(ApiTestCaseMixin): class TestPostWorkoutWithoutGpx(ApiTestCaseMixin):
def test_it_returns_error_if_user_is_not_authenticated(
self, app: Flask, sport_1_cycling: Sport, gpx_file: str
) -> None:
client = app.test_client()
response = client.post(
'/api/workouts/no_gpx',
content_type='application/json',
data=json.dumps(
dict(
sport_id=1,
duration=3600,
workout_date='2018-05-15 14:05',
distance=10,
)
),
headers=dict(content_type='multipart/form-data'),
)
self.assert_401(response)
def test_it_adds_an_workout_without_gpx( def test_it_adds_an_workout_without_gpx(
self, app: Flask, user_1: User, sport_1_cycling: Sport self, app: Flask, user_1: User, sport_1_cycling: Sport
) -> None: ) -> None:

View File

@ -6,12 +6,13 @@ from typing import Dict, Tuple, Union
import jwt import jwt
from flask import Blueprint, current_app, request from flask import Blueprint, current_app, request
from sqlalchemy import exc, func, or_ from sqlalchemy import exc, func
from werkzeug.exceptions import RequestEntityTooLarge from werkzeug.exceptions import RequestEntityTooLarge
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from fittrackee import appLog, bcrypt, db from fittrackee import appLog, bcrypt, db
from fittrackee.emails.tasks import ( from fittrackee.emails.tasks import (
account_confirmation_email,
email_updated_to_current_address, email_updated_to_current_address,
email_updated_to_new_address, email_updated_to_new_address,
password_change_email, password_change_email,
@ -46,6 +47,9 @@ def register_user() -> Union[Tuple[Dict, int], HttpResponse]:
""" """
register a user register a user
The newly created account is inactive. The user must confirm his email
to activate it.
**Example request**: **Example request**:
.. sourcecode:: http .. sourcecode:: http
@ -63,8 +67,6 @@ def register_user() -> Union[Tuple[Dict, int], HttpResponse]:
Content-Type: application/json Content-Type: application/json
{ {
"auth_token": "JSON Web Token",
"message": "successfully registered",
"status": "success" "status": "success"
} }
@ -76,18 +78,18 @@ def register_user() -> Union[Tuple[Dict, int], HttpResponse]:
Content-Type: application/json Content-Type: application/json
{ {
"message": "Errors: email: valid email must be provided\\n", "message": "Errors: email: valid email must be provided\n",
"status": "error" "status": "error"
} }
:<json string username: user name (3 to 30 characters required) :<json string username: username (3 to 30 characters required)
:<json string email: user email :<json string email: user email
:<json string password: password (8 characters required) :<json string password: password (8 characters required)
:statuscode 201: successfully registered :statuscode 201: successfully registered
:statuscode 400: :statuscode 400:
- invalid payload - invalid payload
- sorry, that user already exists - sorry, that username is already taken
- Errors: - Errors:
- username: 3 to 30 characters required - username: 3 to 30 characters required
- username: only alphanumeric characters and the underscore - username: only alphanumeric characters and the underscore
@ -125,30 +127,44 @@ def register_user() -> Union[Tuple[Dict, int], HttpResponse]:
return InvalidPayloadErrorResponse(ret) return InvalidPayloadErrorResponse(ret)
try: try:
# check for existing user
user = User.query.filter( user = User.query.filter(
or_( func.lower(User.username) == func.lower(username)
func.lower(User.username) == func.lower(username),
func.lower(User.email) == func.lower(email),
)
).first() ).first()
if user: if user:
return InvalidPayloadErrorResponse( return InvalidPayloadErrorResponse(
'sorry, that user already exists' 'sorry, that username is already taken'
) )
# add new user to db # if a user exists with same email address, no error is returned
# since a user has to confirm his email to activate his account
user = User.query.filter(
func.lower(User.email) == func.lower(email)
).first()
if not user:
new_user = User(username=username, email=email, password=password) new_user = User(username=username, email=email, password=password)
new_user.timezone = 'Europe/Paris' new_user.timezone = 'Europe/Paris'
new_user.confirmation_token = secrets.token_urlsafe(16)
db.session.add(new_user) db.session.add(new_user)
db.session.commit() db.session.commit()
# generate auth token
auth_token = new_user.encode_auth_token(new_user.id) ui_url = current_app.config['UI_URL']
return { email_data = {
'status': 'success', 'username': new_user.username,
'message': 'successfully registered', 'fittrackee_url': ui_url,
'auth_token': auth_token, 'operating_system': request.user_agent.platform, # type: ignore # noqa
}, 201 'browser_name': request.user_agent.browser, # type: ignore
'account_confirmation_url': (
f'{ui_url}/account-confirmation'
f'?token={new_user.confirmation_token}'
),
}
user_data = {
'language': 'en',
'email': new_user.email,
}
account_confirmation_email.send(user_data, email_data)
return {'status': 'success'}, 200
# handler errors # handler errors
except (exc.IntegrityError, exc.OperationalError, ValueError) as e: except (exc.IntegrityError, exc.OperationalError, ValueError) as e:
return handle_error_and_return_response(e, db=db) return handle_error_and_return_response(e, db=db)
@ -158,6 +174,7 @@ def register_user() -> Union[Tuple[Dict, int], HttpResponse]:
def login_user() -> Union[Dict, HttpResponse]: def login_user() -> Union[Dict, HttpResponse]:
""" """
user login user login
Only user with active account can log in
**Example request**: **Example request**:
@ -209,9 +226,9 @@ def login_user() -> Union[Dict, HttpResponse]:
email = post_data.get('email', '') email = post_data.get('email', '')
password = post_data.get('password') password = post_data.get('password')
try: try:
# check for existing user
user = User.query.filter( user = User.query.filter(
func.lower(User.email) == func.lower(email) func.lower(User.email) == func.lower(email),
User.is_active == True, # noqa
).first() ).first()
if user and bcrypt.check_password_hash(user.password, password): if user and bcrypt.check_password_hash(user.password, password):
# generate auth token # generate auth token
@ -258,6 +275,7 @@ def get_authenticated_user_profile(
"email": "sam@example.com", "email": "sam@example.com",
"first_name": null, "first_name": null,
"imperial_units": false, "imperial_units": false,
"is_active": true,
"language": "en", "language": "en",
"last_name": null, "last_name": null,
"location": null, "location": null,
@ -357,6 +375,7 @@ def edit_user(auth_user: User) -> Union[Dict, HttpResponse]:
"email": "sam@example.com", "email": "sam@example.com",
"first_name": null, "first_name": null,
"imperial_units": false, "imperial_units": false,
"is_active": true,
"language": "en", "language": "en",
"last_name": null, "last_name": null,
"location": null, "location": null,
@ -516,6 +535,7 @@ def update_user_account(auth_user: User) -> Union[Dict, HttpResponse]:
"email": "sam@example.com", "email": "sam@example.com",
"first_name": null, "first_name": null,
"imperial_units": false, "imperial_units": false,
"is_active": true,
"language": "en", "language": "en",
"last_name": null, "last_name": null,
"location": null, "location": null,
@ -709,6 +729,7 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]:
"email": "sam@example.com", "email": "sam@example.com",
"first_name": null, "first_name": null,
"imperial_units": false, "imperial_units": false,
"is_active": true,
"language": "en", "language": "en",
"last_name": null, "last_name": null,
"location": null, "location": null,
@ -1312,3 +1333,63 @@ def update_email() -> Union[Dict, HttpResponse]:
except (exc.OperationalError, ValueError) as e: except (exc.OperationalError, ValueError) as e:
return handle_error_and_return_response(e, db=db) return handle_error_and_return_response(e, db=db)
@auth_blueprint.route('/auth/account/confirm', methods=['POST'])
def confirm_account() -> Union[Dict, HttpResponse]:
"""
activate user account after registration
**Example request**:
.. sourcecode:: http
POST /api/auth/account/confirm HTTP/1.1
Content-Type: application/json
**Example response**:
HTTP/1.1 200 OK
Content-Type: application/json
{
"auth_token": "JSON Web Token",
"message": "account confirmation successful",
"status": "success"
}
:<json string token: confirmation token
:statuscode 200: account confirmation successful
:statuscode 400: invalid payload
:statuscode 500: error, please try again or contact the administrator
"""
post_data = request.get_json()
if not post_data or post_data.get('token') is None:
return InvalidPayloadErrorResponse()
token = post_data.get('token')
try:
user = User.query.filter_by(confirmation_token=token).first()
if not user:
return InvalidPayloadErrorResponse()
user.is_active = True
user.confirmation_token = None
db.session.commit()
# generate auth token
auth_token = user.encode_auth_token(user.id)
response = {
'status': 'success',
'message': 'account confirmation successful',
'auth_token': auth_token,
}
return response
except (exc.OperationalError, ValueError) as e:
return handle_error_and_return_response(e, db=db)

View File

@ -47,6 +47,7 @@ class User(BaseModel):
) )
language = db.Column(db.String(50), nullable=True) language = db.Column(db.String(50), nullable=True)
imperial_units = db.Column(db.Boolean, default=False, nullable=False) imperial_units = db.Column(db.Boolean, default=False, nullable=False)
is_active = db.Column(db.Boolean, default=False, nullable=False)
email_to_confirm = db.Column(db.String(255), nullable=True) email_to_confirm = db.Column(db.String(255), nullable=True)
confirmation_token = db.Column(db.String(255), nullable=True) confirmation_token = db.Column(db.String(255), nullable=True)
@ -149,6 +150,7 @@ class User(BaseModel):
'email': self.email, 'email': self.email,
'email_to_confirm': self.email_to_confirm, 'email_to_confirm': self.email_to_confirm,
'first_name': self.first_name, 'first_name': self.first_name,
'is_active': self.is_active,
'last_name': self.last_name, 'last_name': self.last_name,
'location': self.location, 'location': self.location,
'nb_sports': len(sports), 'nb_sports': len(sports),

View File

@ -52,7 +52,7 @@ def set_admin(username: str) -> None:
@authenticate_as_admin @authenticate_as_admin
def get_users(auth_user: User) -> Dict: def get_users(auth_user: User) -> Dict:
""" """
Get all users Get all users (regardless their account status)
**Example request**: **Example request**:
@ -87,6 +87,7 @@ def get_users(auth_user: User) -> Dict:
"created_at": "Sun, 14 Jul 2019 14:09:58 GMT", "created_at": "Sun, 14 Jul 2019 14:09:58 GMT",
"email": "admin@example.com", "email": "admin@example.com",
"first_name": null, "first_name": null,
"is_admin": true,
"imperial_units": false, "imperial_units": false,
"language": "en", "language": "en",
"last_name": null, "last_name": null,
@ -149,6 +150,7 @@ def get_users(auth_user: User) -> Dict:
"created_at": "Sat, 20 Jul 2019 11:27:03 GMT", "created_at": "Sat, 20 Jul 2019 11:27:03 GMT",
"email": "sam@example.com", "email": "sam@example.com",
"first_name": null, "first_name": null,
"is_admin": false,
"language": "fr", "language": "fr",
"last_name": null, "last_name": null,
"location": null, "location": null,
@ -269,6 +271,7 @@ def get_single_user(
"email": "admin@example.com", "email": "admin@example.com",
"first_name": null, "first_name": null,
"imperial_units": false, "imperial_units": false,
"is_admin": true,
"language": "en", "language": "en",
"last_name": null, "last_name": null,
"location": null, "location": null,
@ -421,6 +424,7 @@ def update_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]:
"email": "admin@example.com", "email": "admin@example.com",
"first_name": null, "first_name": null,
"imperial_units": false, "imperial_units": false,
"is_active": true,
"language": "en", "language": "en",
"last_name": null, "last_name": null,
"location": null, "location": null,

View File

@ -62,8 +62,12 @@ def verify_user(
current_request: Request, verify_admin: bool current_request: Request, verify_admin: bool
) -> Tuple[Optional[HttpResponse], Optional[User]]: ) -> Tuple[Optional[HttpResponse], Optional[User]]:
""" """
Return authenticated user, if the provided token is valid and user has Return authenticated user if
admin rights if 'verify_admin' is True - the provided token is valid
- the user account is active
- the user has admin rights if 'verify_admin' is True
If not, it returns Error Response
""" """
default_message = 'provide a valid auth token' default_message = 'provide a valid auth token'
auth_header = current_request.headers.get('Authorization') auth_header = current_request.headers.get('Authorization')
@ -74,7 +78,7 @@ def verify_user(
if isinstance(resp, str): if isinstance(resp, str):
return UnauthorizedErrorResponse(resp), None return UnauthorizedErrorResponse(resp), None
user = User.query.filter_by(id=resp).first() user = User.query.filter_by(id=resp).first()
if not user: if not user or not user.is_active:
return UnauthorizedErrorResponse(default_message), None return UnauthorizedErrorResponse(default_message), None
if verify_admin and not user.admin: if verify_admin and not user.admin:
return ForbiddenErrorResponse(), None return ForbiddenErrorResponse(), None