API & Client: Profile picture

This commit is contained in:
SamR1 2018-01-01 21:54:03 +01:00
parent 8e21fc4112
commit ddb765e02d
14 changed files with 293 additions and 8 deletions

1
.gitignore vendored
View File

@ -31,3 +31,4 @@ npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
.eslintcache .eslintcache
uploads

View File

@ -1,3 +1,7 @@
import os
from flask import current_app
class BaseConfig: class BaseConfig:
"""Base configuration""" """Base configuration"""
DEBUG = False DEBUG = False
@ -6,6 +10,10 @@ class BaseConfig:
BCRYPT_LOG_ROUNDS = 13 BCRYPT_LOG_ROUNDS = 13
TOKEN_EXPIRATION_DAYS = 30 TOKEN_EXPIRATION_DAYS = 30
TOKEN_EXPIRATION_SECONDS = 0 TOKEN_EXPIRATION_SECONDS = 0
UPLOAD_FOLDER = os.path.join(
current_app.root_path, 'uploads'
)
PICTURE_ALLOWED_EXTENSIONS = {'jpg', 'png', 'gif'}
class DevelopmentConfig(BaseConfig): class DevelopmentConfig(BaseConfig):

View File

@ -1,5 +1,6 @@
import json import json
import time import time
from io import BytesIO
from mpwo_api.tests.base import BaseTestCase from mpwo_api.tests.base import BaseTestCase
from mpwo_api.tests.utils import add_user, add_user_full from mpwo_api.tests.utils import add_user, add_user_full
@ -485,3 +486,31 @@ class TestAuthBlueprint(BaseTestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertIn('Invalid payload.', data['message']) self.assertIn('Invalid payload.', data['message'])
self.assertIn('error', data['status']) self.assertIn('error', data['status'])
def test_update_user_picture(self):
add_user('test', 'test@test.com', '12345678')
with self.client:
resp_login = self.client.post(
'/api/auth/login',
data=json.dumps(dict(
email='test@test.com',
password='12345678'
)),
content_type='application/json'
)
response = self.client.post(
'/api/auth/picture',
data=dict(
file=(BytesIO(b'avatar'), 'avatar.png')
),
headers=dict(
content_type='multipart/form-data',
authorization='Bearer ' +
json.loads(resp_login.data.decode())['auth_token']
)
)
data = json.loads(response.data.decode())
self.assertTrue(data['status'] == 'success')
self.assertTrue(data['message'] == 'User picture updated.')
self.assertEqual(response.status_code, 200)

View File

@ -1,11 +1,13 @@
import datetime import datetime
from flask import Blueprint, current_app, jsonify, request import os
from flask import Blueprint, current_app, jsonify, request, send_from_directory
from sqlalchemy import exc, or_ from sqlalchemy import exc, or_
from werkzeug.utils import secure_filename
from mpwo_api import appLog, bcrypt, db from mpwo_api import appLog, bcrypt, db
from .models import User from .models import User
from .utils import authenticate, register_controls from .utils import allowed_picture, authenticate, register_controls
auth_blueprint = Blueprint('auth', __name__) auth_blueprint = Blueprint('auth', __name__)
@ -161,6 +163,7 @@ def get_user_status(user_id):
'bio': user.bio, 'bio': user.bio,
'location': user.location, 'location': user.location,
'birth_date': user.birth_date, 'birth_date': user.birth_date,
'picture': True if user.picture else False,
} }
} }
return jsonify(response_object), 200 return jsonify(response_object), 200
@ -227,3 +230,81 @@ def edit_user(user_id):
'message': 'Error. Please try again or contact the administrator.' 'message': 'Error. Please try again or contact the administrator.'
} }
return jsonify(response_object), 500 return jsonify(response_object), 500
@auth_blueprint.route('/auth/picture', methods=['POST'])
@authenticate
def edit_picture(user_id):
code = 400
if 'file' not in request.files:
response_object = {'status': 'fail', 'message': 'No file part.'}
return jsonify(response_object), code
file = request.files['file']
if file.filename == '':
response_object = {'status': 'fail', 'message': 'No selected file.'}
return jsonify(response_object), code
if not allowed_picture(file.filename):
response_object = {
'status': 'fail',
'message': 'File extension not allowed.'
}
return jsonify(response_object), code
filename = secure_filename(file.filename)
dirpath = os.path.join(
current_app.config['UPLOAD_FOLDER'],
'pictures',
str(user_id)
)
if not os.path.exists(dirpath):
os.makedirs(dirpath)
filepath = os.path.join(dirpath, filename)
try:
user = User.query.filter_by(id=user_id).first()
if user.picture is not None and os.path.isfile(user.picture):
os.remove(user.picture)
file.save(filepath)
user.picture = filepath
db.session.commit()
response_object = {
'status': 'success',
'message': 'User picture updated.'
}
return jsonify(response_object), 200
except (exc.IntegrityError, ValueError) as e:
db.session.rollback()
appLog.error(e)
response_object = {
'status': 'fail',
'message': 'Error during picture update.'
}
return jsonify(response_object), 500
@auth_blueprint.route('/auth/picture', methods=['DELETE'])
@authenticate
def del_picture(user_id):
try:
user = User.query.filter_by(id=user_id).first()
if os.path.isfile(user.picture):
os.remove(user.picture)
user.picture = None
db.session.commit()
response_object = {
'status': 'success',
'message': 'User picture delete.'
}
return jsonify(response_object), 200
except (exc.IntegrityError, ValueError) as e:
db.session.rollback()
appLog.error(e)
response_object = {
'status': 'fail',
'message': 'Error during picture deletion.'
}
return jsonify(response_object), 500

View File

@ -19,6 +19,7 @@ class User(db.Model):
birth_date = db.Column(db.DateTime, nullable=True) birth_date = db.Column(db.DateTime, nullable=True)
location = db.Column(db.String(80), nullable=True) location = db.Column(db.String(80), nullable=True)
bio = db.Column(db.String(200), nullable=True) bio = db.Column(db.String(200), nullable=True)
picture = db.Column(db.String(255), nullable=True)
def __repr__(self): def __repr__(self):
return '<User %r>' % self.username return '<User %r>' % self.username

View File

@ -1,4 +1,4 @@
from flask import Blueprint, jsonify from flask import Blueprint, jsonify, send_file
from .models import User from .models import User
@ -52,6 +52,22 @@ def get_single_user(user_id):
return jsonify(response_object), 404 return jsonify(response_object), 404
@users_blueprint.route('/users/<user_id>/picture', methods=['GET'])
def get_picture(user_id):
response_object = {
'status': 'fail',
'message': 'User does not exist'
}
try:
user = User.query.filter_by(id=int(user_id)).first()
if not user:
return jsonify(response_object), 404
else:
return send_file(user.picture)
except ValueError:
return jsonify(response_object), 404
@users_blueprint.route('/ping', methods=['GET']) @users_blueprint.route('/ping', methods=['GET'])
def ping_pong(): def ping_pong():
return jsonify({ return jsonify({

View File

@ -1,11 +1,17 @@
from functools import wraps from functools import wraps
import re import re
from flask import request, jsonify from flask import current_app, jsonify, request
from .models import User from .models import User
def allowed_picture(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in \
current_app.config.get('PICTURE_ALLOWED_EXTENSIONS')
def authenticate(f): def authenticate(f):
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):

View File

@ -11,6 +11,10 @@ function AuthErrors(messages) {
return { type: 'AUTH_ERRORS', messages } return { type: 'AUTH_ERRORS', messages }
} }
function PictureError(message) {
return { type: 'PICTURE_ERROR', message }
}
function ProfileSuccess(message) { function ProfileSuccess(message) {
return { type: 'PROFILE_SUCCESS', message } return { type: 'PROFILE_SUCCESS', message }
} }
@ -151,7 +155,7 @@ export function handleProfileFormSubmit(event) {
event.preventDefault() event.preventDefault()
return (dispatch, getState) => { return (dispatch, getState) => {
const state = getState() const state = getState()
if (state.formProfile.formProfile.password !== if (!state.formProfile.formProfile.password ===
state.formProfile.formProfile.passwordConf) { state.formProfile.formProfile.passwordConf) {
dispatch(PwdError('Password and password confirmation don\'t match.')) dispatch(PwdError('Password and password confirmation don\'t match.'))
} else { } else {
@ -172,3 +176,41 @@ export function handleProfileFormSubmit(event) {
} }
} }
export function uploadPicture (event) {
event.preventDefault()
const form = new FormData()
form.append('file', event.target.picture.files[0])
event.target.reset()
return function(dispatch) {
return mpwoApi
.updatePicture(form)
.then(ret => {
if (ret.status === 'success') {
getProfile(dispatch)
} else {
dispatch(PictureError(ret.message))
}
})
.catch(error => {
throw error
})
}
}
export function deletePicture() {
return function(dispatch) {
return mpwoApi
.deletePicture()
.then(ret => {
if (ret.status === 'success') {
getProfile(dispatch)
} else {
dispatch(PictureError(ret.message))
}
})
.catch(error => {
throw error
})
}
}

View File

@ -22,6 +22,18 @@
font-size: large; font-size: large;
} }
.App-nav-profile-img {
max-width: 35px;
max-height: 35px;
border-radius: 50%;
}
.App-profile-img-small {
max-width: 150px;
max-height: 150px;
border-radius: 50%;
}
@keyframes App-logo-spin { @keyframes App-logo-spin {
from { transform: rotate(0deg); } from { transform: rotate(0deg); }
to { transform: rotate(360deg); } to { transform: rotate(360deg); }

View File

@ -2,6 +2,9 @@ import React from 'react'
import { connect } from 'react-redux' import { connect } from 'react-redux'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import mpwoApi from '../../mpwoApi'
function NavBar (props) { function NavBar (props) {
return ( return (
<header> <header>
@ -56,6 +59,14 @@ function NavBar (props) {
</Link> </Link>
</li> </li>
)} )}
{props.user.picture === true && (
<img
alt="Profile"
src={`${mpwoApi.getApiUrl()}users/${props.user.id}/picture` +
`?${Date.now()}`}
className="img-fluid App-nav-profile-img"
/>
)}
{props.user.isAuthenticated && ( {props.user.isAuthenticated && (
<li className="nav-item"> <li className="nav-item">
<Link <Link

View File

@ -3,12 +3,18 @@ import { Helmet } from 'react-helmet'
import { connect } from 'react-redux' import { connect } from 'react-redux'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
function Profile ({ user }) { import mpwoApi from '../../mpwoApi'
import { deletePicture, uploadPicture } from '../../actions'
function Profile ({ message, onDeletePicture, onUploadPicture, user }) {
return ( return (
<div> <div>
<Helmet> <Helmet>
<title>mpwo - {user.username} - Profile</title> <title>mpwo - {user.username} - Profile</title>
</Helmet> </Helmet>
{ message !== '' && (
<code>{message}</code>
)}
<div className="container"> <div className="container">
<h1 className="page-title">Profile</h1> <h1 className="page-title">Profile</h1>
<div className="row"> <div className="row">
@ -35,6 +41,38 @@ function Profile ({ user }) {
<p>Location : {user.location}</p> <p>Location : {user.location}</p>
<p>Bio : {user.bio}</p> <p>Bio : {user.bio}</p>
</div> </div>
<div className="col-md-4">
{ user.picture === true && (
<div>
<img
alt="Profile"
src={`${mpwoApi.getApiUrl()}users/${user.id}/picture` +
`?${Date.now()}`}
className="img-fluid App-profile-img-small"
/>
<br />
<button
type="submit"
onClick={() => onDeletePicture()}
>
Delete picture
</button>
<br /><br />
</div>
)}
<form
encType="multipart/form-data"
onSubmit={event => onUploadPicture(event)}
>
<input
type="file"
name="picture"
accept=".png,.jpg,.gif"
/>
<br />
<button type="submit">Send</button>
</form>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -55,6 +93,15 @@ function Profile ({ user }) {
export default connect( export default connect(
state => ({ state => ({
message: state.message,
user: state.user, user: state.user,
}),
dispatch => ({
onDeletePicture: () => {
dispatch(deletePicture())
},
onUploadPicture: event => {
dispatch(uploadPicture(event))
},
}) })
)(Profile) )(Profile)

View File

@ -67,4 +67,30 @@ export default class MpwoApi {
.then(response => response.json()) .then(response => response.json())
.catch(error => error) .catch(error => error)
} }
static updatePicture(form) {
const request = new Request(`${apiUrl}auth/picture`, {
method: 'POST',
headers: new Headers({
Authorization: `Bearer ${window.localStorage.getItem('authToken')}`,
}),
body: form,
})
return fetch(request)
.then(response => response.json())
.catch(error => error)
}
static deletePicture() {
const request = new Request(`${apiUrl}auth/picture`, {
method: 'DELETE',
headers: new Headers({
Authorization: `Bearer ${window.localStorage.getItem('authToken')}`,
}),
})
return fetch(request)
.then(response => response.json())
.catch(error => error)
}
static getApiUrl() {
return apiUrl
}
} }

View File

@ -50,6 +50,7 @@ const message = (state = initial.message, action) => {
case 'AUTH_ERROR': case 'AUTH_ERROR':
case 'PROFILE_ERROR': case 'PROFILE_ERROR':
case 'PWD_ERROR': case 'PWD_ERROR':
case 'PICTURE_ERROR':
return action.message return action.message
case 'LOGOUT': case 'LOGOUT':
case 'PROFILE_SUCCESS': case 'PROFILE_SUCCESS':
@ -103,6 +104,9 @@ const user = (state = initial.user, action) => {
birthDate: action.message.data.birth_date birthDate: action.message.data.birth_date
? action.message.data.birth_date ? action.message.data.birth_date
: '', : '',
picture: action.message.data.picture === true
? action.message.data.picture
: false,
} }
default: default:
return state return state

View File

@ -12,7 +12,8 @@ export default {
lastName: '', lastName: '',
bio: '', bio: '',
location: '', location: '',
birthDate: '' birthDate: '',
picture: false
}, },
formData: { formData: {
formData: { formData: {
@ -30,7 +31,7 @@ export default {
location: '', location: '',
birthDate: '', birthDate: '',
password: '', password: '',
passwordConf: '' passwordConf: '',
} }
}, },
} }