API & Client: Profile picture
This commit is contained in:
parent
8e21fc4112
commit
ddb765e02d
1
.gitignore
vendored
1
.gitignore
vendored
@ -31,3 +31,4 @@ npm-debug.log*
|
|||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
uploads
|
||||||
|
@ -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):
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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({
|
||||||
|
@ -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):
|
||||||
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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); }
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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: '',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user