API & Client - sport administration & refactor - #15

This commit is contained in:
Sam 2019-09-22 23:03:56 +02:00
parent a10128f13e
commit 1f8de2eccc
30 changed files with 518 additions and 673 deletions

View File

@ -4,4 +4,5 @@ Sports
.. autoflask:: fittrackee_api:create_app()
:endpoints:
sports.get_sports,
sports.get_sport
sports.get_sport,
sports.update_sport

View File

@ -142,39 +142,45 @@
<span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span>
<span class="nt">&quot;sports&quot;</span><span class="p">:</span> <span class="p">[</span>
<span class="p">{</span>
<span class="nt">&quot;_can_be_deleted&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;_can_be_disabled&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/cycling-sport.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="nt">&quot;_can_be_deleted&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;_can_be_disabled&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/cycling-transport.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Transport)&quot;</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="nt">&quot;_can_be_deleted&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;_can_be_disabled&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/hiking.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Hiking&quot;</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="nt">&quot;_can_be_deleted&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;_can_be_disabled&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">4</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/mountain-biking.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Mountain Biking&quot;</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="nt">&quot;_can_be_deleted&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;_can_be_disabled&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">5</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/running.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Running&quot;</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="nt">&quot;_can_be_deleted&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;_can_be_disabled&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">6</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/walking.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Walking&quot;</span>
<span class="p">}</span>
<span class="p">]</span>
@ -228,9 +234,10 @@
<span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span>
<span class="nt">&quot;sports&quot;</span><span class="p">:</span> <span class="p">[</span>
<span class="p">{</span>
<span class="nt">&quot;_can_be_deleted&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;_can_be_disabled&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/cycling-sport.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span>
<span class="p">}</span>
<span class="p">]</span>
@ -280,6 +287,86 @@
</dl>
</dd></dl>
<dl class="patch">
<dt id="patch--api-sports-(int-sport_id)">
<code class="sig-name descname">PATCH </code><code class="sig-name descname">/api/sports/</code><span class="sig-paren">(</span><em class="property">int: </em><em class="sig-param">sport_id</em><span class="sig-paren">)</span><a class="headerlink" href="#patch--api-sports-(int-sport_id)" title="Permalink to this definition"></a></dt>
<dd><p>Update a sport</p>
<p><strong>Example request</strong>:</p>
<div class="highlight-http notranslate"><div class="highlight"><pre><span></span><span class="nf">PATCH</span> <span class="nn">/api/sports/1</span> <span class="kr">HTTP</span><span class="o">/</span><span class="m">1.1</span>
<span class="na">Content-Type</span><span class="o">:</span> <span class="l">application/json</span>
</pre></div>
</div>
<p><strong>Example response</strong>:</p>
<ul class="simple">
<li><p>success</p></li>
</ul>
<div class="highlight-http notranslate"><div class="highlight"><pre><span></span><span class="kr">HTTP</span><span class="o">/</span><span class="m">1.1</span> <span class="m">200</span> <span class="ne">OK</span>
<span class="na">Content-Type</span><span class="o">:</span> <span class="l">application/json</span>
<span class="p">{</span>
<span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span>
<span class="nt">&quot;sports&quot;</span><span class="p">:</span> <span class="p">[</span>
<span class="p">{</span>
<span class="nt">&quot;_can_be_disabled&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/cycling-sport.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span>
<span class="p">}</span>
<span class="p">]</span>
<span class="p">},</span>
<span class="nt">&quot;status&quot;</span><span class="p">:</span> <span class="s2">&quot;success&quot;</span>
<span class="p">}</span>
</pre></div>
</div>
<ul class="simple">
<li><p>sport not found</p></li>
</ul>
<div class="highlight-http notranslate"><div class="highlight"><pre><span></span><span class="kr">HTTP</span><span class="o">/</span><span class="m">1.1</span> <span class="m">404</span> <span class="ne">NOT FOUND</span>
<span class="na">Content-Type</span><span class="o">:</span> <span class="l">application/json</span>
<span class="p">{</span>
<span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span>
<span class="nt">&quot;sports&quot;</span><span class="p">:</span> <span class="p">[]</span>
<span class="p">},</span>
<span class="nt">&quot;status&quot;</span><span class="p">:</span> <span class="s2">&quot;not found&quot;</span>
<span class="p">}</span>
</pre></div>
</div>
<dl class="field-list simple">
<dt class="field-odd">Parameters</dt>
<dd class="field-odd"><ul class="simple">
<li><p><strong>auth_user_id</strong> (<em>integer</em>) authenticate user id (from JSON Web Token)</p></li>
<li><p><strong>sport_id</strong> (<em>integer</em>) sport id</p></li>
</ul>
</dd>
<dt class="field-even">Request JSON Object</dt>
<dd class="field-even"><ul class="simple">
<li><p><strong>is_active</strong> (<em>string</em>) sport active status</p></li>
</ul>
</dd>
<dt class="field-odd">Request Headers</dt>
<dd class="field-odd"><ul class="simple">
<li><p><a class="reference external" href="https://tools.ietf.org/html/rfc7235#section-4.2">Authorization</a> OAuth 2.0 Bearer Token</p></li>
</ul>
</dd>
<dt class="field-even">Status Codes</dt>
<dd class="field-even"><ul class="simple">
<li><p><a class="reference external" href="http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.2.1">200 OK</a> sport updated</p></li>
<li><p><a class="reference external" href="http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.1">400 Bad Request</a> invalid payload</p></li>
<li><p><a class="reference external" href="http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.2">401 Unauthorized</a> <ul>
<li><p>Provide a valid auth token.</p></li>
<li><p>Signature expired. Please log in again.</p></li>
<li><p>Invalid token. Please log in again.</p></li>
</ul>
</p></li>
<li><p><a class="reference external" href="http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.5">404 Not Found</a> sport not found</p></li>
<li><p><a class="reference external" href="http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.5.1">500 Internal Server Error</a> </p></li>
</ul>
</dd>
</dl>
</dd></dl>
</div>

View File

@ -259,6 +259,11 @@
<td>
<a href="api/activities.html#patch--api-activities-(int-activity_id)"><code class="xref">PATCH /api/activities/(int:activity_id)</code></a></td><td>
<em></em></td></tr>
<tr>
<td></td>
<td>
<a href="api/sports.html#patch--api-sports-(int-sport_id)"><code class="xref">PATCH /api/sports/(int:sport_id)</code></a></td><td>
<em></em></td></tr>
</table>

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -4,4 +4,5 @@ Sports
.. autoflask:: fittrackee_api:create_app()
:endpoints:
sports.get_sports,
sports.get_sport
sports.get_sport,
sports.update_sport

View File

@ -60,7 +60,7 @@ class Sport(db.Model):
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
label = db.Column(db.String(50), unique=True, nullable=False)
img = db.Column(db.String(255), unique=True, nullable=True)
is_default = db.Column(db.Boolean, default=False, nullable=False)
is_active = db.Column(db.Boolean, default=True, nullable=False)
activities = db.relationship(
'Activity', lazy=True, backref=db.backref('sports', lazy='joined')
)
@ -79,8 +79,10 @@ class Sport(db.Model):
'id': self.id,
'label': self.label,
'img': self.img,
'_can_be_deleted': len(self.activities) == 0
and not self.is_default,
'is_active': self.is_active,
'_can_be_disabled': not (
len(self.activities) > 0 and self.is_active
),
}

View File

@ -32,39 +32,45 @@ def get_sports(auth_user_id):
"data": {
"sports": [
{
"_can_be_deleted": false,
"_can_be_disabled": false,
"id": 1,
"img": "/img/sports/cycling-sport.png",
"is_active": true,
"label": "Cycling (Sport)"
},
{
"_can_be_deleted": false,
"_can_be_disabled": false,
"id": 2,
"img": "/img/sports/cycling-transport.png",
"is_active": true,
"label": "Cycling (Transport)"
},
{
"_can_be_deleted": false,
"_can_be_disabled": false,
"id": 3,
"img": "/img/sports/hiking.png",
"is_active": true,
"label": "Hiking"
},
{
"_can_be_deleted": false,
"_can_be_disabled": false,
"id": 4,
"img": "/img/sports/mountain-biking.png",
"is_active": true,
"label": "Mountain Biking"
},
{
"_can_be_deleted": false,
"_can_be_disabled": false,
"id": 5,
"img": "/img/sports/running.png",
"is_active": true,
"label": "Running"
},
{
"_can_be_deleted": false,
"_can_be_disabled": false,
"id": 6,
"img": "/img/sports/walking.png",
"is_active": true,
"label": "Walking"
}
]
@ -117,9 +123,10 @@ def get_sport(auth_user_id, sport_id):
"data": {
"sports": [
{
"_can_be_deleted": false,
"_can_be_disabled": false,
"id": 1,
"img": "/img/sports/cycling-sport.png",
"is_active": true,
"label": "Cycling (Sport)"
}
]
@ -168,64 +175,101 @@ def get_sport(auth_user_id, sport_id):
return jsonify(response_object), code
# no administration - no documentation for now
@sports_blueprint.route('/sports', methods=['POST'])
@authenticate_as_admin
def post_sport(auth_user_id):
"""Post a sport"""
sport_data = request.get_json()
if not sport_data or sport_data.get('label') is None:
response_object = {'status': 'error', 'message': 'Invalid payload.'}
return jsonify(response_object), 400
try:
new_sport = Sport(label=sport_data.get('label'))
db.session.add(new_sport)
db.session.commit()
response_object = {
'status': 'created',
'data': {'sports': [new_sport.serialize()]},
}
code = 201
except (exc.IntegrityError, exc.OperationalError, ValueError) as e:
db.session.rollback()
appLog.error(e)
response_object = {
'status': 'error',
'message': 'Error. Please try again or contact the administrator.',
}
code = 500
return jsonify(response_object), code
@sports_blueprint.route('/sports/<int:sport_id>', methods=['PATCH'])
@authenticate_as_admin
def update_sport(auth_user_id, sport_id):
"""Update a sport"""
"""Update a sport
**Example request**:
.. sourcecode:: http
PATCH /api/sports/1 HTTP/1.1
Content-Type: application/json
**Example response**:
- success
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": {
"sports": [
{
"_can_be_disabled": false,
"id": 1,
"img": "/img/sports/cycling-sport.png",
"is_active": false,
"label": "Cycling (Sport)"
}
]
},
"status": "success"
}
- sport not found
.. sourcecode:: http
HTTP/1.1 404 NOT FOUND
Content-Type: application/json
{
"data": {
"sports": []
},
"status": "not found"
}
:param integer auth_user_id: authenticate user id (from JSON Web Token)
:param integer sport_id: sport id
:<json string is_active: sport active status
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: sport updated
:statuscode 400: invalid payload
:statuscode 401:
- Provide a valid auth token.
- Signature expired. Please log in again.
- Invalid token. Please log in again.
:statuscode 404: sport not found
:statuscode 500:
"""
sport_data = request.get_json()
if not sport_data or sport_data.get('label') is None:
if not sport_data or sport_data.get('is_active') is None:
response_object = {'status': 'error', 'message': 'Invalid payload.'}
return jsonify(response_object), 400
sports_list = []
try:
sport = Sport.query.filter_by(id=sport_id).first()
if sport:
sport.label = sport_data.get('label')
db.session.commit()
sports_list.append({'id': sport.id, 'label': sport.label})
response_object = {
'status': 'success',
'data': {'sports': sports_list},
}
code = 200
if not (
not sport_data.get('is_active')
and sport.is_active
and len(sport.activities) > 0
):
sport.is_active = sport_data.get('is_active')
db.session.commit()
response_object = {
'status': 'success',
'data': {'sports': [sport.serialize()]},
}
code = 200
else:
response_object = {
'status': 'fail',
'message': 'Sport can not be disabled, activities exist.',
}
code = 400
else:
response_object = {
'status': 'not found',
'data': {'sports': sports_list},
}
response_object = {'status': 'not found', 'data': {'sports': []}}
code = 404
except (exc.IntegrityError, exc.OperationalError, ValueError) as e:
db.session.rollback()
@ -236,36 +280,3 @@ def update_sport(auth_user_id, sport_id):
}
code = 500
return jsonify(response_object), code
@sports_blueprint.route('/sports/<int:sport_id>', methods=['DELETE'])
@authenticate_as_admin
def delete_sport(auth_user_id, sport_id):
"""Delete a sport"""
try:
sport = Sport.query.filter_by(id=sport_id).first()
if sport:
db.session.delete(sport)
db.session.commit()
response_object = {'status': 'no content'}
code = 204
else:
response_object = {'status': 'not found', 'data': {'sports': []}}
code = 404
except exc.IntegrityError as e:
db.session.rollback()
appLog.error(e)
response_object = {
'status': 'error',
'message': 'Error. Associated activities exist.',
}
code = 500
except (exc.OperationalError, ValueError) as e:
db.session.rollback()
appLog.error(e)
response_object = {
'status': 'error',
'message': 'Error. Please try again or contact the administrator.',
}
code = 500
return jsonify(response_object), code

View File

@ -4,14 +4,16 @@ expected_sport_1_cycling_result = {
'id': 1,
'label': 'Cycling',
'img': None,
'_can_be_deleted': True,
'is_active': True,
'_can_be_disabled': True,
}
expected_sport_2_running_result = {
'id': 2,
'label': 'Running',
'img': None,
'_can_be_deleted': True,
'is_active': True,
'_can_be_disabled': True,
}
@ -83,78 +85,6 @@ def test_get_a_sport_invalid(app, user_1):
assert len(data['data']['sports']) == 0
def test_add_a_sport(app, user_1_admin):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='admin@example.com', password='12345678')),
content_type='application/json',
)
response = client.post(
'/api/sports',
content_type='application/json',
data=json.dumps(dict(label='Cycling')),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 201
assert 'created' in data['status']
assert len(data['data']['sports']) == 1
assert data['data']['sports'][0] == expected_sport_1_cycling_result
def test_add_a_sport_not_admin(app, user_1):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.post(
'/api/sports',
content_type='application/json',
data=json.dumps(dict(label='surfing')),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 403
assert 'created' not in data['status']
assert 'error' in data['status']
assert 'You do not have permissions.' in data['message']
def test_add_a_sport_invalid_payload(app, user_1_admin):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='admin@example.com', password='12345678')),
content_type='application/json',
)
response = client.post(
'/api/sports',
content_type='application/json',
data=json.dumps(dict()),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 400
assert 'error' in data['status']
assert 'Invalid payload.' in data['message']
def test_update_a_sport(app, user_1_admin, sport_1_cycling):
client = app.test_client()
resp_login = client.post(
@ -165,7 +95,7 @@ def test_update_a_sport(app, user_1_admin, sport_1_cycling):
response = client.patch(
'/api/sports/1',
content_type='application/json',
data=json.dumps(dict(label='cycling updated')),
data=json.dumps(dict(is_active=False)),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
@ -177,7 +107,49 @@ def test_update_a_sport(app, user_1_admin, sport_1_cycling):
assert 'success' in data['status']
assert len(data['data']['sports']) == 1
assert 'cycling updated' in data['data']['sports'][0]['label']
assert data['data']['sports'][0]['is_active'] is False
response = client.patch(
'/api/sports/1',
content_type='application/json',
data=json.dumps(dict(is_active=True)),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['sports']) == 1
assert data['data']['sports'][0]['is_active'] is True
def test_disable_a_sport_with_activities(
app, user_1_admin, sport_1_cycling, activity_cycling_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='admin@example.com', password='12345678')),
content_type='application/json',
)
response = client.patch(
'/api/sports/1',
content_type='application/json',
data=json.dumps(dict(is_active=False)),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 400
assert 'fail' in data['status']
assert 'Sport can not be disabled, activities exist.' in data['message']
def test_update_a_sport_not_admin(app, user_1, sport_1_cycling):
@ -190,7 +162,7 @@ def test_update_a_sport_not_admin(app, user_1, sport_1_cycling):
response = client.patch(
'/api/sports/1',
content_type='application/json',
data=json.dumps(dict(label='cycling updated')),
data=json.dumps(dict(is_active=False)),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
@ -237,7 +209,7 @@ def test_update_a_sport_invalid_id(app, user_1_admin):
response = client.patch(
'/api/sports/1',
content_type='application/json',
data=json.dumps(dict(label='cycling updated')),
data=json.dumps(dict(is_active=False)),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
@ -248,91 +220,3 @@ def test_update_a_sport_invalid_id(app, user_1_admin):
assert response.status_code == 404
assert 'not found' in data['status']
assert len(data['data']['sports']) == 0
def test_delete_a_sport(app, user_1_admin, sport_1_cycling):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='admin@example.com', password='12345678')),
content_type='application/json',
)
response = client.delete(
'/api/sports/1',
content_type='application/json',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
assert response.status_code == 204
def test_delete_a_sport_not_admin(app, user_1, sport_1_cycling):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.delete(
'/api/sports/1',
content_type='application/json',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 403
assert 'error' in data['status']
assert 'You do not have permissions.' in data['message']
def test_delete_a_sport_invalid_id(app, user_1_admin):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='admin@example.com', password='12345678')),
content_type='application/json',
)
response = client.delete(
'/api/sports/1',
content_type='application/json',
data=json.dumps(dict()),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 404
assert 'not found' in data['status']
assert len(data['data']['sports']) == 0
def test_delete_a_sport_with_an_activity(
app, user_1_admin, sport_1_cycling, activity_cycling_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='admin@example.com', password='12345678')),
content_type='application/json',
)
response = client.delete(
'/api/sports/1',
content_type='application/json',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 500
assert 'error' in data['status']
assert 'Error. Associated activities exist.' in data['message']

View File

@ -6,7 +6,8 @@ def test_sport_model(app, sport_1_cycling):
serialized_sport = sport_1_cycling.serialize()
assert 1 == serialized_sport['id']
assert 'Cycling' == serialized_sport['label']
assert serialized_sport['_can_be_deleted'] is True
assert serialized_sport['is_active'] is True
assert serialized_sport['_can_be_disabled'] is True
def test_sport_model_with_activity(
@ -19,4 +20,5 @@ def test_sport_model_with_activity(
serialized_sport = sport_1_cycling.serialize()
assert 1 == serialized_sport['id']
assert 'Cycling' == serialized_sport['label']
assert serialized_sport['_can_be_deleted'] is False
assert serialized_sport['is_active'] is True
assert serialized_sport['_can_be_disabled'] is False

View File

@ -0,0 +1,36 @@
"""replace 'is_default' with 'is_active' in 'Sports' table
Revision ID: 1345afe3b11d
Revises: f69f1e413bde
Create Date: 2019-09-22 17:57:00.595775
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1345afe3b11d'
down_revision = 'f69f1e413bde'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('sports',
sa.Column('is_active',
sa.Boolean(create_constraint=50),
nullable=True))
op.execute("UPDATE sports SET is_active = true")
op.alter_column('sports', 'is_active', nullable=False)
op.drop_column('sports', 'is_default')
def downgrade():
op.add_column('sports',
sa.Column('is_default',
sa.Boolean(create_constraint=50),
nullable=True))
op.execute("UPDATE sports SET is_default = true")
op.alter_column('sports', 'is_default', nullable=False)
op.drop_column('sports', 'is_active')

View File

@ -24,14 +24,29 @@ export const setLoading = loading => ({
loading,
})
export const getOrUpdateData = (action, target, data) => dispatch => {
export const updateSportsData = data => ({
type: 'UPDATE_SPORT_DATA',
data,
})
export const getOrUpdateData = (
action,
target,
data,
canDispatch = true
) => dispatch => {
if (data && data.id && isNaN(data.id)) {
return dispatch(setError(`${target}|Incorrect id`))
}
dispatch(setError(''))
return FitTrackeeApi[action](target, data)
.then(ret => {
if (ret.status === 'success') {
dispatch(setData(target, ret.data))
if (canDispatch) {
dispatch(setData(target, ret.data))
} else if (action === 'updateData' && target === 'sports') {
dispatch(updateSportsData(ret.data.sports[0]))
}
} else {
dispatch(setError(`${target}|${ret.message || ret.status}`))
}

View File

@ -74,7 +74,7 @@ class ActivityDisplay extends React.Component {
} = this.props
const { coordinates, displayModal } = this.state
const [activity] = activities
const title = activity ? activity.title : 'Activity'
const title = activity ? activity.title : t('activities:Activity')
const [sport] = activity
? sports.filter(s => s.id === activity.sport_id)
: []

View File

@ -5,7 +5,8 @@ import { capitalize } from '../../utils/index'
const menuItems = ['application', 'sports', 'users']
export default function AdminMenu() {
export default function AdminMenu(props) {
const { t } = props
return (
<div>
<ul className="admin-items">
@ -16,7 +17,7 @@ export default function AdminMenu() {
pathname: `/admin/${item}`,
}}
>
{capitalize(item)}
{t(`administration:${capitalize(item)}`)}
</Link>
</li>
))}

View File

@ -1,36 +0,0 @@
import React from 'react'
import { connect } from 'react-redux'
import { getOrUpdateData } from '../../../actions'
import AdminDetail from '../generic/AdminDetail'
class AdminSports extends React.Component {
componentDidMount() {
this.props.loadSport(this.props.match.params.sportId)
}
componentWillUnmount() {
// reload all Sports
this.props.loadSport(null)
}
render() {
const { sports } = this.props
return (
<div>
<AdminDetail results={sports} target="sports" />
</div>
)
}
}
export default connect(
state => ({
sports: state.sports.data,
user: state.user,
}),
dispatch => ({
loadSport: sportId => {
dispatch(getOrUpdateData('getData', 'sports', { id: sportId }))
},
})
)(AdminSports)

View File

@ -1,42 +0,0 @@
import React from 'react'
import { connect } from 'react-redux'
import { Route, Switch } from 'react-router-dom'
import { getOrUpdateData } from '../../../actions'
import AdminPage from '../generic/AdminPage'
import AdminSport from './AdminSport'
import AdminSportsAdd from './AdminSportsAdd'
import NotFound from '../../Others/NotFound'
class AdminSports extends React.Component {
componentDidMount() {
this.props.loadSports()
}
render() {
const { sports } = this.props
return (
<Switch>
<Route
exact
path="/admin/sports"
render={() => <AdminPage data={sports} target="sports" />}
/>
<Route exact path="/admin/sports/add" component={AdminSportsAdd} />
<Route exact path="/admin/sports/:sportId" component={AdminSport} />
<Route component={NotFound} />
</Switch>
)
}
}
export default connect(
state => ({
sports: state.sports,
user: state.user,
}),
dispatch => ({
loadSports: () => {
dispatch(getOrUpdateData('getData', 'sports'))
},
})
)(AdminSports)

View File

@ -1,75 +0,0 @@
import React from 'react'
import { Helmet } from 'react-helmet'
import { connect } from 'react-redux'
import { addData } from '../../../actions/index'
import { history } from '../../../index'
class AdminSportsAdd extends React.Component {
componentDidMount() {}
render() {
const { message, onAddSport } = this.props
return (
<div>
<Helmet>
<title>FitTrackee - Admin - Add Sport</title>
</Helmet>
<h1 className="page-title">Administration - Sport</h1>
{message && <code>{message}</code>}
<div className="container">
<div className="row">
<div className="col-md-2" />
<div className="col-md-8">
<div className="card">
<div className="card-header">Add a sport</div>
<div className="card-body">
<form onSubmit={event => event.preventDefault()}>
<div className="form-group">
<label>
Label:
<input
name="label"
className="form-control input-lg"
type="text"
/>
</label>
</div>
<input
type="submit"
className="btn btn-primary btn-lg btn-block"
onClick={event => onAddSport(event)}
value="Submit"
/>
<input
type="submit"
className="btn btn-secondary btn-lg btn-block"
onClick={() => history.push('/admin/sports')}
value="Cancel"
/>
</form>
</div>
</div>
</div>
<div className="col-md-2" />
</div>
</div>
</div>
)
}
}
export default connect(
state => ({
message: state.message,
user: state.user,
}),
dispatch => ({
onAddSport: e => {
const data = { label: e.target.form.label.value }
dispatch(addData('sports', data))
},
})
)(AdminSportsAdd)

View File

@ -0,0 +1,121 @@
import React from 'react'
import { connect } from 'react-redux'
import { Helmet } from 'react-helmet'
import Message from '../../Common/Message'
import { getOrUpdateData } from '../../../actions'
import { history } from '../../../index'
class AdminSports extends React.Component {
componentDidMount() {
this.props.loadSports()
}
render() {
const { message, sports, t, updateSport } = this.props
return (
<div>
<Helmet>
<title>FitTrackee - {t('administration:Administration')}</title>
</Helmet>
{message && <Message message={message} t={t} />}
<div className="container">
<div className="row">
<div className="col card">
<div className="card-body">
{sports.length > 0 && (
<table className="table">
<thead>
<tr>
<th>{t('administration:id')}</th>
<th>{t('administration:Image')}</th>
<th>{t('administration:Label')}</th>
<th>{t('administration:Active')}</th>
<th>{t('administration:Actions')}</th>
</tr>
</thead>
<tbody>
{sports.map(sport => (
<tr key={sport.id}>
<th scope="row">{sport.id}</th>
<td>
<img
className="admin-img"
src={sport.img ? sport.img : '/img/photo.png'}
alt="sport logo"
/>
</td>
<td>{t(`sports:${sport.label}`)}</td>
<td>
{sport.is_active ? (
<i
className="fa fa-check-square-o custom-fa"
aria-hidden="true"
data-toggle="tooltip"
/>
) : (
<i
className="fa fa-square-o custom-fa"
aria-hidden="true"
data-toggle="tooltip"
/>
)}
</td>
<td>
{sport._can_be_disabled ? (
<input
type="submit"
className={`btn btn-${
sport.is_active ? 'dark' : 'primary'
} btn-sm`}
value={
sport.is_active
? t('administration:Disable')
: t('administration:Enable')
}
onClick={() =>
updateSport(sport.id, !sport.is_active)
}
/>
) : (
<span className="admin-message">
{t('administration:activities exist')}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
<input
type="submit"
className="btn btn-secondary btn-lg btn-block"
onClick={() => history.push('/admin/')}
value={t('administration:Back')}
/>
</div>
</div>
</div>
</div>
</div>
)
}
}
export default connect(
state => ({
message: state.message,
sports: state.sports.data,
user: state.user,
}),
dispatch => ({
loadSports: () => {
dispatch(getOrUpdateData('getData', 'sports'))
},
updateSport: (sportId, isActive) => {
const data = { id: sportId, is_active: isActive }
dispatch(getOrUpdateData('updateData', 'sports', data, false))
},
})
)(AdminSports)

View File

@ -1,141 +0,0 @@
import React from 'react'
import { Helmet } from 'react-helmet'
import { connect } from 'react-redux'
import { deleteData, getOrUpdateData } from '../../../actions/index'
import { history } from '../../../index'
class AdminDetail extends React.Component {
constructor(props, context) {
super(props, context)
this.state = {
isInEdition: false,
}
}
render() {
const { message, onDataUpdate, onDataDelete, results, target } = this.props
const { isInEdition } = this.state
return (
<div>
<Helmet>
<title>FitTrackee - Admin</title>
</Helmet>
{message ? (
<code>{message}</code>
) : (
results.length === 1 && (
<div className="container">
<div className="row">
<div className="col card">
<div className="card-body">
<form onSubmit={event => event.preventDefault()}>
{Object.keys(results[0])
.filter(key => key.charAt(0) !== '_')
.map(key => (
<div className="form-group" key={key}>
<label>
{key}:
{key === 'img' ? (
<img
src={
results[0][key]
? results[0][key]
: '/img/photo.png'
}
alt="property"
/>
) : (
<input
className="form-control input-lg"
name={key}
readOnly={key === 'id' || !isInEdition}
defaultValue={results[0][key]}
/>
)}
</label>
</div>
))}
{isInEdition ? (
<div>
<input
type="submit"
className="btn btn-primary btn-lg btn-block"
onClick={event => {
onDataUpdate(event, target)
this.setState({ isInEdition: false })
}}
value="Submit"
/>
<input
type="submit"
className="btn btn-secondary btn-lg btn-block"
onClick={event => {
event.target.form.reset()
this.setState({ isInEdition: false })
}}
value="Cancel"
/>
</div>
) : (
<div>
<input
type="submit"
className="btn btn-primary btn-lg btn-block"
onClick={() => this.setState({ isInEdition: true })}
value="Edit"
/>
<input
type="submit"
className="btn btn-danger btn-lg btn-block"
disabled={!results[0]._can_be_deleted}
onClick={event => onDataDelete(event, target)}
title={
results[0]._can_be_deleted
? ''
: "Can't be deleted, associated data exist"
}
value="Delete"
/>
<input
type="submit"
className="btn btn-secondary btn-lg btn-block"
onClick={() => history.push(`/admin/${target}`)}
value="Back to the list"
/>
</div>
)}
</form>
</div>
</div>
</div>
</div>
)
)}
</div>
)
}
}
export default connect(
state => ({
message: state.message,
}),
dispatch => ({
onDataDelete: (e, target) => {
const id = e.target.form.id.value
dispatch(deleteData(target, id))
},
onDataUpdate: (e, target) => {
const data = [].slice
.call(e.target.form.elements)
.reduce(function(map, obj) {
if (obj.name) {
map[obj.name] = obj.value
}
return map
}, {})
dispatch(getOrUpdateData('updateData', target, data))
},
})
)(AdminDetail)

View File

@ -1,94 +0,0 @@
import React from 'react'
import { Helmet } from 'react-helmet'
import { Link } from 'react-router-dom'
import { history } from '../../../index'
export default function AdminPage(props) {
const { data, target } = props
const { error } = data
const results = data.data
const tbKeys = []
if (results.length > 0) {
Object.keys(results[0])
.filter(key => key.charAt(0) !== '_')
.map(key => tbKeys.push(key))
}
return (
<div>
<Helmet>
<title>FitTrackee - Admin</title>
</Helmet>
{error ? (
<code>{error}</code>
) : (
<div className="container">
<div className="row">
<div className="col card">
<div className="card-body">
<table className="table">
<thead>
<tr>
{tbKeys.map(tbKey => (
<th key={tbKey} scope="col">
{tbKey}
</th>
))}
</tr>
</thead>
<tbody>
{results.map((result, idx) => (
// eslint-disable-next-line react/no-array-index-key
<tr key={idx}>
{Object.keys(result)
.filter(key => key.charAt(0) !== '_')
.map(key => {
if (key === 'id') {
return (
<th key={key} scope="row">
<Link to={`/admin/${target}/${result[key]}`}>
{result[key]}
</Link>
</th>
)
} else if (key === 'img') {
return (
<td key={key}>
<img
className="admin-img"
src={
result[key]
? result[key]
: '/img/photo.png'
}
alt="logo"
/>
</td>
)
}
return <td key={key}>{result[key]}</td>
})}
</tr>
))}
</tbody>
</table>
<input
type="submit"
className="btn btn-primary btn-lg btn-block"
onClick={() => history.push(`/admin/${target}/add`)}
value="Add a new item"
/>
<input
type="submit"
className="btn btn-secondary btn-lg btn-block"
onClick={() => history.push('/admin/')}
value="Back"
/>
</div>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -1,21 +1,22 @@
import React from 'react'
import { Helmet } from 'react-helmet'
import { withTranslation } from 'react-i18next'
import { connect } from 'react-redux'
import { Link, Redirect, Route, Switch } from 'react-router-dom'
import AdminDashboard from './AdminDashboard'
import AdminMenu from './AdminMenu'
import AdminSports from './Sports/AdminSports'
import AdminSports from './Sports'
import AccessDenied from './../Others/AccessDenied'
import NotFound from './../Others/NotFound'
import { isLoggedIn } from '../../utils'
function Admin(props) {
const { user } = props
const { t, user } = props
return (
<div>
<Helmet>
<title>FitTrackee - Admin</title>
<title>FitTrackee - {t('administration:Administration')}</title>
</Helmet>
<div className="container dashboard">
<div className="row">
@ -27,11 +28,11 @@ function Admin(props) {
pathname: '/admin/',
}}
>
Administration
{t('administration:Administration')}
</Link>
</div>
<div className="card-body">
<AdminMenu />
<AdminMenu t={t} />
</div>
</div>
</div>
@ -39,8 +40,16 @@ function Admin(props) {
{isLoggedIn() ? (
user.admin ? (
<Switch>
<Route exact path="/admin" component={AdminDashboard} />
<Route path="/admin/sports" component={AdminSports} />
<Route
exact
path="/admin"
render={() => <AdminDashboard t={t} />}
/>
<Route
exact
path="/admin/sports"
render={() => <AdminSports t={t} />}
/>
<Route component={NotFound} />
</Switch>
) : (
@ -56,6 +65,8 @@ function Admin(props) {
)
}
export default connect(state => ({
user: state.user,
}))(Admin)
export default withTranslation()(
connect(state => ({
user: state.user,
}))(Admin)
)

View File

@ -170,6 +170,12 @@ label {
list-style-type: square;
}
.admin-message {
color: #7c7c7d;
font-size: 0.9em;
font-style: italic;
}
.card {
text-align: left;
}

View File

@ -3,6 +3,7 @@ import LanguageDetector from 'i18next-browser-languagedetector'
import XHR from 'i18next-xhr-backend'
import EnActivitiesTranslations from './locales/en/activities.json'
import EnAdministrationTranslations from './locales/en/administration.json'
import EnCommonTranslations from './locales/en/common.json'
import EnDashboardTranslations from './locales/en/dashboard.json'
import EnMessagesTranslations from './locales/en/messages.json'
@ -10,6 +11,7 @@ import EnSportsTranslations from './locales/en/sports.json'
import EnStatisticsTranslations from './locales/en/statistics.json'
import EnUserTranslations from './locales/en/user.json'
import FrActivitiesTranslations from './locales/fr/activities.json'
import FrAdministrationTranslations from './locales/fr/administration.json'
import FrCommonTranslations from './locales/fr/common.json'
import FrDashboardTranslations from './locales/fr/dashboard.json'
import FrMessagesTranslations from './locales/fr/messages.json'
@ -31,6 +33,7 @@ i18n
resources: {
en: {
activities: EnActivitiesTranslations,
administration: EnAdministrationTranslations,
common: EnCommonTranslations,
dashboard: EnDashboardTranslations,
messages: EnMessagesTranslations,
@ -40,6 +43,7 @@ i18n
},
fr: {
activities: FrActivitiesTranslations,
administration: FrAdministrationTranslations,
common: FrCommonTranslations,
dashboard: FrDashboardTranslations,
messages: FrMessagesTranslations,

View File

@ -1,4 +1,5 @@
{
"Activity": "Activity",
"Activity Date": "Activity Date",
"Add a workout": "Add a workout",
"Are you sure you want to delete this activity?": "Are you sure you want to delete this activity?",

View File

@ -0,0 +1,15 @@
{
"Actions": "Actions",
"Active": "Active",
"activities exist": "activities exist",
"Administration": "Administration",
"Application": "Application",
"Back": "Back",
"Disable": "Disable",
"Enable": "Enable",
"id": "id",
"Image": "Image",
"Label": "Label",
"Sports": "Sports",
"Users": "Users"
}

View File

@ -23,6 +23,7 @@
"records": "records",
"Signature expired. Please log in again.": "Signature expired. Please log in again.",
"Sorry. That user already exists.": "Sorry. That user already exists.",
"Sport can not be disabled, activities exist." : "Sport can not be disabled, activities exist.",
"Sport does not exist.": "Sport does not exist.",
"sports": "sports",
"statistics": "statistiques",

View File

@ -1,4 +1,5 @@
{
"Activity": "Activité",
"Activity Date": "Date de l'activité",
"Add a workout": "Ajouter une activité",
"Are you sure you want to delete this activity?": "Etes-vous sûr de vouloir supprimer cette activité ?",

View File

@ -0,0 +1,15 @@
{
"Actions": "Actions",
"Active": "Active",
"Administration": "Administration",
"activities exist": "des activités existent",
"Application": "Application",
"Back": "Retour",
"Disable": "désactiver",
"Enable": "activer",
"id": "id",
"Image": "Image",
"Label": "Label",
"Sports": "Sports",
"Users": "Utilisateurs"
}

View File

@ -23,6 +23,7 @@
"records": "records",
"Signature expired. Please log in again.": "Signature expirée. Merci de vous reconnecter.",
"Sorry. That user already exists.": "Désolé. Cet utilisateur existe déjà.",
"Sport can not be disabled, activities exist." : "Le sport ne peut être désactivé, des activitées existent",
"Sport does not exist.": "Le sport n'existe pas.",
"sports": "sports",
"statistics": "statistics",

View File

@ -105,8 +105,20 @@ const messages = (state = initial.messages, action) => {
const records = (state = initial.records, action) =>
handleDataAndError(state, 'records', action)
const sports = (state = initial.sports, action) =>
handleDataAndError(state, 'sports', action)
const sports = (state = initial.sports, action) => {
if (action.type === 'UPDATE_SPORT_DATA') {
return {
...state,
data: state.data.map(sport => {
if (sport.id === action.data.id) {
sport.is_active = action.data.is_active
}
return sport
}),
}
}
return handleDataAndError(state, 'sports', action)
}
const user = (state = initial.user, action) => {
switch (action.type) {