diff --git a/.gitignore b/.gitignore index ca3898b2..694f46a6 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ npm-debug.log* yarn-debug.log* yarn-error.log* .eslintcache +uploads diff --git a/mpwo_api/mpwo_api/config.py b/mpwo_api/mpwo_api/config.py index 0088a9eb..f9828ab9 100644 --- a/mpwo_api/mpwo_api/config.py +++ b/mpwo_api/mpwo_api/config.py @@ -1,3 +1,7 @@ +import os +from flask import current_app + + class BaseConfig: """Base configuration""" DEBUG = False @@ -6,6 +10,10 @@ class BaseConfig: BCRYPT_LOG_ROUNDS = 13 TOKEN_EXPIRATION_DAYS = 30 TOKEN_EXPIRATION_SECONDS = 0 + UPLOAD_FOLDER = os.path.join( + current_app.root_path, 'uploads' + ) + PICTURE_ALLOWED_EXTENSIONS = {'jpg', 'png', 'gif'} class DevelopmentConfig(BaseConfig): diff --git a/mpwo_api/mpwo_api/tests/test_auth.py b/mpwo_api/mpwo_api/tests/test_auth.py index 2fb02354..4b363049 100644 --- a/mpwo_api/mpwo_api/tests/test_auth.py +++ b/mpwo_api/mpwo_api/tests/test_auth.py @@ -1,5 +1,6 @@ import json import time +from io import BytesIO from mpwo_api.tests.base import BaseTestCase 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.assertIn('Invalid payload.', data['message']) 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) diff --git a/mpwo_api/mpwo_api/users/auth.py b/mpwo_api/mpwo_api/users/auth.py index 68104210..cf41233d 100644 --- a/mpwo_api/mpwo_api/users/auth.py +++ b/mpwo_api/mpwo_api/users/auth.py @@ -1,11 +1,13 @@ 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 werkzeug.utils import secure_filename from mpwo_api import appLog, bcrypt, db from .models import User -from .utils import authenticate, register_controls +from .utils import allowed_picture, authenticate, register_controls auth_blueprint = Blueprint('auth', __name__) @@ -161,6 +163,7 @@ def get_user_status(user_id): 'bio': user.bio, 'location': user.location, 'birth_date': user.birth_date, + 'picture': True if user.picture else False, } } return jsonify(response_object), 200 @@ -227,3 +230,81 @@ def edit_user(user_id): 'message': 'Error. Please try again or contact the administrator.' } 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 diff --git a/mpwo_api/mpwo_api/users/models.py b/mpwo_api/mpwo_api/users/models.py index ea86b7ff..22bb5363 100644 --- a/mpwo_api/mpwo_api/users/models.py +++ b/mpwo_api/mpwo_api/users/models.py @@ -19,6 +19,7 @@ class User(db.Model): birth_date = db.Column(db.DateTime, nullable=True) location = db.Column(db.String(80), nullable=True) bio = db.Column(db.String(200), nullable=True) + picture = db.Column(db.String(255), nullable=True) def __repr__(self): return '' % self.username diff --git a/mpwo_api/mpwo_api/users/users.py b/mpwo_api/mpwo_api/users/users.py index e0227679..2d70dae4 100644 --- a/mpwo_api/mpwo_api/users/users.py +++ b/mpwo_api/mpwo_api/users/users.py @@ -1,4 +1,4 @@ -from flask import Blueprint, jsonify +from flask import Blueprint, jsonify, send_file from .models import User @@ -52,6 +52,22 @@ def get_single_user(user_id): return jsonify(response_object), 404 +@users_blueprint.route('/users//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']) def ping_pong(): return jsonify({ diff --git a/mpwo_api/mpwo_api/users/utils.py b/mpwo_api/mpwo_api/users/utils.py index 8b9350ae..589950b2 100644 --- a/mpwo_api/mpwo_api/users/utils.py +++ b/mpwo_api/mpwo_api/users/utils.py @@ -1,11 +1,17 @@ from functools import wraps import re -from flask import request, jsonify +from flask import current_app, jsonify, request 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): @wraps(f) def decorated_function(*args, **kwargs): diff --git a/mpwo_client/src/actions/index.js b/mpwo_client/src/actions/index.js index d4fb2445..ccc3aa43 100644 --- a/mpwo_client/src/actions/index.js +++ b/mpwo_client/src/actions/index.js @@ -11,6 +11,10 @@ function AuthErrors(messages) { return { type: 'AUTH_ERRORS', messages } } +function PictureError(message) { + return { type: 'PICTURE_ERROR', message } +} + function ProfileSuccess(message) { return { type: 'PROFILE_SUCCESS', message } } @@ -151,7 +155,7 @@ export function handleProfileFormSubmit(event) { event.preventDefault() return (dispatch, getState) => { const state = getState() - if (state.formProfile.formProfile.password !== + if (!state.formProfile.formProfile.password === state.formProfile.formProfile.passwordConf) { dispatch(PwdError('Password and password confirmation don\'t match.')) } 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 + }) + } +} diff --git a/mpwo_client/src/components/App.css b/mpwo_client/src/components/App.css index 5ffd7aff..1e8feaf5 100644 --- a/mpwo_client/src/components/App.css +++ b/mpwo_client/src/components/App.css @@ -22,6 +22,18 @@ 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 { from { transform: rotate(0deg); } to { transform: rotate(360deg); } diff --git a/mpwo_client/src/components/NavBar/index.jsx b/mpwo_client/src/components/NavBar/index.jsx index 6019c7cb..153b4ad5 100644 --- a/mpwo_client/src/components/NavBar/index.jsx +++ b/mpwo_client/src/components/NavBar/index.jsx @@ -2,6 +2,9 @@ import React from 'react' import { connect } from 'react-redux' import { Link } from 'react-router-dom' +import mpwoApi from '../../mpwoApi' + + function NavBar (props) { return (
@@ -56,6 +59,14 @@ function NavBar (props) { )} + {props.user.picture === true && ( + Profile + )} {props.user.isAuthenticated && (
  • mpwo - {user.username} - Profile + { message !== '' && ( + {message} + )}

    Profile

    @@ -35,6 +41,38 @@ function Profile ({ user }) {

    Location : {user.location}

    Bio : {user.bio}

    +
    + { user.picture === true && ( +
    + Profile +
    + +

    +
    + )} +
    onUploadPicture(event)} + > + +
    + +
    +
    @@ -55,6 +93,15 @@ function Profile ({ user }) { export default connect( state => ({ + message: state.message, user: state.user, + }), + dispatch => ({ + onDeletePicture: () => { + dispatch(deletePicture()) + }, + onUploadPicture: event => { + dispatch(uploadPicture(event)) + }, }) )(Profile) diff --git a/mpwo_client/src/mpwoApi.js b/mpwo_client/src/mpwoApi.js index 0977e4ba..31455618 100644 --- a/mpwo_client/src/mpwoApi.js +++ b/mpwo_client/src/mpwoApi.js @@ -67,4 +67,30 @@ export default class MpwoApi { .then(response => response.json()) .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 + } } diff --git a/mpwo_client/src/reducers/index.js b/mpwo_client/src/reducers/index.js index acedc4a4..738c119f 100644 --- a/mpwo_client/src/reducers/index.js +++ b/mpwo_client/src/reducers/index.js @@ -50,6 +50,7 @@ const message = (state = initial.message, action) => { case 'AUTH_ERROR': case 'PROFILE_ERROR': case 'PWD_ERROR': + case 'PICTURE_ERROR': return action.message case 'LOGOUT': case 'PROFILE_SUCCESS': @@ -103,6 +104,9 @@ const user = (state = initial.user, action) => { birthDate: action.message.data.birth_date ? action.message.data.birth_date : '', + picture: action.message.data.picture === true + ? action.message.data.picture + : false, } default: return state diff --git a/mpwo_client/src/reducers/initial.js b/mpwo_client/src/reducers/initial.js index 7ebd6bad..9747c677 100644 --- a/mpwo_client/src/reducers/initial.js +++ b/mpwo_client/src/reducers/initial.js @@ -12,7 +12,8 @@ export default { lastName: '', bio: '', location: '', - birthDate: '' + birthDate: '', + picture: false }, formData: { formData: { @@ -30,7 +31,7 @@ export default { location: '', birthDate: '', password: '', - passwordConf: '' + passwordConf: '', } }, }