API & Client: Profile update

This commit is contained in:
SamR1 2018-01-01 16:59:46 +01:00
parent cac4f368bf
commit 2c4f3c4462
16 changed files with 524 additions and 76 deletions

View File

@ -2,6 +2,9 @@ include Makefile.config
-include Makefile.custom.config -include Makefile.custom.config
.SILENT: .SILENT:
init-db:
$(FLASK) init_db
make-p: make-p:
# Launch all P targets in parallel and exit as soon as one exits. # Launch all P targets in parallel and exit as soon as one exits.
set -m; (for p in $(P); do ($(MAKE) $$p || kill 0)& done; wait) set -m; (for p in $(P); do ($(MAKE) $$p || kill 0)& done; wait)

View File

@ -2,7 +2,7 @@ import json
import time import time
from mpwo_api.tests.base import BaseTestCase from mpwo_api.tests.base import BaseTestCase
from mpwo_api.tests.utils import add_user from mpwo_api.tests.utils import add_user, add_user_full
class TestAuthBlueprint(BaseTestCase): class TestAuthBlueprint(BaseTestCase):
@ -328,14 +328,14 @@ class TestAuthBlueprint(BaseTestCase):
data['message'] == 'Invalid token. Please log in again.') data['message'] == 'Invalid token. Please log in again.')
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
def test_user_profile(self): def test_user_profile_minimal(self):
add_user('test', 'test@test.com', 'test') add_user('test', 'test@test.com', '12345678')
with self.client: with self.client:
resp_login = self.client.post( resp_login = self.client.post(
'/api/auth/login', '/api/auth/login',
data=json.dumps(dict( data=json.dumps(dict(
email='test@test.com', email='test@test.com',
password='test' password='12345678'
)), )),
content_type='application/json' content_type='application/json'
) )
@ -356,6 +356,39 @@ class TestAuthBlueprint(BaseTestCase):
self.assertFalse(data['data']['admin']) self.assertFalse(data['data']['admin'])
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_user_profile_full(self):
add_user_full('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.get(
'/api/auth/profile',
headers=dict(
Authorization='Bearer ' + json.loads(
resp_login.data.decode()
)['auth_token']
)
)
data = json.loads(response.data.decode())
self.assertTrue(data['status'] == 'success')
self.assertTrue(data['data'] is not None)
self.assertTrue(data['data']['username'] == 'test')
self.assertTrue(data['data']['email'] == 'test@test.com')
self.assertTrue(data['data']['created_at'])
self.assertFalse(data['data']['admin'])
self.assertTrue(data['data']['first_name'] == 'John')
self.assertTrue(data['data']['last_name'] == 'Doe')
self.assertTrue(data['data']['birth_date'])
self.assertTrue(data['data']['bio'] == 'just a random guy')
self.assertTrue(data['data']['location'] == 'somewhere')
self.assertEqual(response.status_code, 200)
def test_invalid_profile(self): def test_invalid_profile(self):
with self.client: with self.client:
response = self.client.get( response = self.client.get(
@ -366,3 +399,89 @@ class TestAuthBlueprint(BaseTestCase):
self.assertTrue( self.assertTrue(
data['message'] == 'Invalid token. Please log in again.') data['message'] == 'Invalid token. Please log in again.')
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
def test_user_profile_valid_update(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/profile/edit',
content_type='application/json',
data=json.dumps(dict(
first_name='John',
last_name='Doe',
location='Somewhere',
bio='just a random guy',
birth_date='01/01/1980'
)),
headers=dict(
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 profile updated.')
self.assertEqual(response.status_code, 200)
def test_user_profile_valid_update_with_one_field(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/profile/edit',
content_type='application/json',
data=json.dumps(dict(
first_name='John'
)),
headers=dict(
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 profile updated.')
self.assertEqual(response.status_code, 200)
def test_user_profile_update_invalid_json(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/profile/edit',
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())
self.assertEqual(response.status_code, 400)
self.assertIn('Invalid payload.', data['message'])
self.assertIn('error', data['status'])

View File

@ -1,3 +1,5 @@
import datetime
from mpwo_api import db from mpwo_api import db
from mpwo_api.users.models import User from mpwo_api.users.models import User
@ -7,3 +9,15 @@ def add_user(username, email, password):
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
return user return user
def add_user_full(username, email, password):
user = User(username=username, email=email, password=password)
user.first_name = 'John'
user.last_name = 'Doe'
user.bio = 'just a random guy'
user.location = 'somewhere'
user.birth_date = datetime.datetime.strptime('01/01/1980', '%d/%m/%Y')
db.session.add(user)
db.session.commit()
return user

View File

@ -1,3 +1,4 @@
import datetime
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from sqlalchemy import exc, or_ from sqlalchemy import exc, or_
@ -155,6 +156,57 @@ def get_user_status(user_id):
'email': user.email, 'email': user.email,
'created_at': user.created_at, 'created_at': user.created_at,
'admin': user.admin, 'admin': user.admin,
'first_name': user.first_name,
'last_name': user.last_name,
'bio': user.bio,
'location': user.location,
'birth_date': user.birth_date,
} }
} }
return jsonify(response_object), 200 return jsonify(response_object), 200
@auth_blueprint.route('/auth/profile/edit', methods=['POST'])
@authenticate
def edit_user(user_id):
# get post data
post_data = request.get_json()
if not post_data:
response_object = {
'status': 'error',
'message': 'Invalid payload.'
}
return jsonify(response_object), 400
first_name = post_data.get('first_name')
last_name = post_data.get('last_name')
bio = post_data.get('bio')
birth_date = post_data.get('birth_date')
location = post_data.get('location')
try:
user = User.query.filter_by(id=user_id).first()
user.first_name = first_name
user.last_name = last_name
user.bio = bio
user.location = location
user.birth_date = (
datetime.datetime.strptime(birth_date, '%d/%m/%Y')
if birth_date
else None
)
db.session.commit()
response_object = {
'status': 'success',
'message': 'User profile updated.'
}
return jsonify(response_object), 200
# handler errors
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.'
}
return jsonify(response_object), 500

View File

@ -9,18 +9,23 @@ from mpwo_api import bcrypt, db
class User(db.Model): class User(db.Model):
__tablename__ = "users" __tablename__ = "users"
id = db.Column(db.Integer, primary_key=True, autoincrement=True) id = db.Column(db.Integer, primary_key=True, autoincrement=True)
username = db.Column(db.String(80), unique=True, nullable=False) username = db.Column(db.String(20), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False) email = db.Column(db.String(120), unique=True, nullable=False)
password = db.Column(db.String(255), nullable=False) password = db.Column(db.String(255), nullable=False)
created_at = db.Column(db.DateTime, nullable=False) created_at = db.Column(db.DateTime, nullable=False)
admin = db.Column(db.Boolean, default=False, nullable=False) admin = db.Column(db.Boolean, default=False, nullable=False)
first_name = db.Column(db.String(80), nullable=True)
last_name = db.Column(db.String(80), nullable=True)
birth_date = db.Column(db.DateTime, nullable=True)
location = db.Column(db.String(80), nullable=True)
bio = db.Column(db.String(200), nullable=True)
def __repr__(self): def __repr__(self):
return '<User %r>' % self.username return '<User %r>' % self.username
def __init__( def __init__(
self, username, email, password, self, username, email, password,
created_at=datetime.datetime.utcnow()): created_at=datetime.datetime.now()):
self.username = username self.username = username
self.email = email self.email = email
self.password = bcrypt.generate_password_hash( self.password = bcrypt.generate_password_hash(
@ -30,7 +35,11 @@ class User(db.Model):
@staticmethod @staticmethod
def encode_auth_token(user_id): def encode_auth_token(user_id):
"""Generates the auth token""" """
Generates the auth token
:param user_id: -
:return: JWToken
"""
try: try:
payload = { payload = {
'exp': datetime.datetime.utcnow() + datetime.timedelta( 'exp': datetime.datetime.utcnow() + datetime.timedelta(

View File

@ -5,21 +5,18 @@ from mpwo_api.users.models import User
@app.cli.command() @app.cli.command()
def recreate_db(): def init_db():
"""Recreates a database.""" """Init the database."""
db.drop_all() db.drop_all()
db.create_all() db.create_all()
db.session.commit() admin = User(
print('Database (re)creation done.') username='admin',
email='admin@example.com',
password='mpwoadmin')
@app.cli.command()
def seed_db():
"""Seeds the database."""
admin = User(username='admin', email='admin@example.com', password='admin')
admin.admin = True admin.admin = True
db.session.add(admin) db.session.add(admin)
db.session.commit() db.session.commit()
print('Database initialization done.')
@app.cli.command() @app.cli.command()

View File

@ -1,5 +1,7 @@
import keyIndex from 'react-key-index' import keyIndex from 'react-key-index'
import mpwoApi from '../mpwoApi' import mpwoApi from '../mpwoApi'
import { history } from '../index'
function AuthError(message) { function AuthError(message) {
return { type: 'AUTH_ERROR', message } return { type: 'AUTH_ERROR', message }
@ -17,25 +19,21 @@ function ProfileError(message) {
return { type: 'PROFILE_ERROR', message } return { type: 'PROFILE_ERROR', message }
} }
const updateFormDataEmail = value => ({ export const handleFormChange = (target, value) => ({
type: 'UPDATE_FORMDATA_EMAIL', type: 'UPDATE_USER_FORMDATA',
email: value, target,
value,
}) })
const updateFormDataUsername = value => ({ export const updateProfileFormData = (target, value) => ({
type: 'UPDATE_FORMDATA_USERNAME', type: 'UPDATE_PROFILE_FORMDATA',
username: value, target,
value,
}) })
const updateFormDataPassword = value => ({ function initProfileFormData(user) {
type: 'UPDATE_FORMDATA_PASSWORD', return { type: 'INIT_PROFILE_FORM', user }
password: value, }
})
const updateFormDataPasswordConf = value => ({
type: 'UPDATE_FORMDATA_PASSWORD_CONF',
passwordConf: value,
})
export function getProfile(dispatch) { export function getProfile(dispatch) {
return mpwoApi return mpwoApi
@ -43,7 +41,6 @@ export function getProfile(dispatch) {
.then(ret => { .then(ret => {
if (ret.status === 'success') { if (ret.status === 'success') {
dispatch(ProfileSuccess(ret)) dispatch(ProfileSuccess(ret))
// window.location.href = '/'
} else { } else {
dispatch(ProfileError(ret.message)) dispatch(ProfileError(ret.message))
} }
@ -139,15 +136,29 @@ export function handleUserFormSubmit(event, formType) {
} }
} }
export const handleFormChange = event => dispatch => { export function initProfileForm () {
switch (event.target.name) { return (dispatch, getState) => {
case 'email': const state = getState()
return dispatch(updateFormDataEmail(event.target.value)) dispatch(initProfileFormData(state.user))
case 'username': }
return dispatch(updateFormDataUsername(event.target.value)) }
case 'password':
return dispatch(updateFormDataPassword(event.target.value)) export function handleProfileFormSubmit(event) {
default: // case 'password-conf': event.preventDefault()
return dispatch(updateFormDataPasswordConf(event.target.value)) return (dispatch, getState) => {
const state = getState()
return mpwoApi
.updateProfile(state.formProfile.formProfile)
.then(ret => {
if (ret.status === 'success') {
getProfile(dispatch)
history.push('/profile')
} else {
dispatch(AuthError(ret.message))
}
})
.catch(error => {
throw error
})
} }
} }

View File

@ -31,6 +31,14 @@
text-align: left; text-align: left;
} }
label {
width: 100%;
}
input, textarea {
width: 100%;
}
.page-title { .page-title {
font-size: 2em; font-size: 2em;
margin: 1em; margin: 1em;

View File

@ -7,6 +7,7 @@ import Logout from './User/Logout'
import NavBar from './NavBar' import NavBar from './NavBar'
import NotFound from './NotFound' import NotFound from './NotFound'
import Profile from './User/Profile' import Profile from './User/Profile'
import ProfileEdit from './User/ProfileEdit'
import UserForm from './User/UserForm' import UserForm from './User/UserForm'
import { isLoggedIn } from '../utils' import { isLoggedIn } from '../utils'
@ -57,6 +58,18 @@ export default class App extends React.Component {
)} )}
/> />
<Route exact path="/logout" component={Logout} /> <Route exact path="/logout" component={Logout} />
<Route
exact path="/profile/edit"
render={() => (
isLoggedIn() ? (
<ProfileEdit />
) : (
<UserForm
formType={'Login'}
/>
)
)}
/>
<Route <Route
exact path="/profile" exact path="/profile"
render={() => ( render={() => (

View File

@ -51,10 +51,10 @@ export default function Form (props) {
{props.formType === 'Register' && {props.formType === 'Register' &&
<div className="form-group"> <div className="form-group">
<input <input
name="password-conf" name="passwordConf"
className="form-control input-lg" className="form-control input-lg"
type="password" type="password"
placeholder="Enter password confirmation" placeholder="Enter the password confirmation"
required required
onChange={props.onHandleFormChange} onChange={props.onHandleFormChange}
/> />

View File

@ -28,7 +28,12 @@ function Profile ({ user }) {
<div className="row"> <div className="row">
<div className="col-md-8"> <div className="col-md-8">
<p>Email : {user.email}</p> <p>Email : {user.email}</p>
<p>Registration date : {user.createdAt}</p> <p>Registration Date : {user.createdAt}</p>
<p>First Name : {user.firstName}</p>
<p>Last Name : {user.lastName}</p>
<p>Birth Date : {user.birthDate}</p>
<p>Location : {user.location}</p>
<p>Bio : {user.bio}</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,162 @@
import React from 'react'
import { Helmet } from 'react-helmet'
import { connect } from 'react-redux'
import {
initProfileForm,
updateProfileFormData,
handleProfileFormSubmit
} from '../../actions'
class ProfileEdit extends React.Component {
componentDidMount() {
this.props.initForm(this.props.user)
}
render () {
const { formProfile,
onHandleFormChange,
onHandleProfileFormSubmit,
user
} = this.props
return (
<div>
<Helmet>
<title>mpwo - {user.username} - Edit Profile</title>
</Helmet>
<div className="container">
<h1 className="page-title">Profile Edition</h1>
<div className="row">
<div className="col-md-2" />
<div className="col-md-8">
<div className="card">
<div className="card-header">
{user.username}
</div>
<div className="card-body">
<div className="row">
<div className="col-md-12">
<form onSubmit={event =>
onHandleProfileFormSubmit(event)}
>
<div className="form-group">
<label>Email:
<input
name="email"
className="form-control input-lg"
type="text"
value={user.email}
readOnly
/>
</label>
</div>
<div className="form-group">
<label>
Registration Date:
<input
name="createdAt"
className="form-control input-lg"
type="text"
value={user.createdAt}
readOnly
/>
</label>
</div>
<div className="form-group">
<label>
First Name:
<input
name="firstName"
className="form-control input-lg"
type="text"
value={formProfile.firstName}
onChange={onHandleFormChange}
/>
</label>
</div>
<div className="form-group">
<label>
Last Name:
<input
name="lastName"
className="form-control input-lg"
type="text"
value={formProfile.lastName}
onChange={onHandleFormChange}
/>
</label>
</div>
<div className="form-group">
<label>
Birth Date
<input
name="birthDate"
className="form-control input-lg"
type="text"
value={formProfile.birthDate}
onChange={onHandleFormChange}
/>
</label>
</div>
<div className="form-group">
<label>
Location:
<input
name="location"
className="form-control input-lg"
type="text"
value={formProfile.location}
onChange={onHandleFormChange}
/>
</label>
</div>
<div className="form-group">
<label>
Bio:
<textarea
name="bio"
className="form-control input-lg"
maxLength="200"
type="text"
value={formProfile.bio}
onChange={onHandleFormChange}
/>
</label>
</div>
<input
type="submit"
className="btn btn-primary btn-lg btn-block"
value="Submit"
/>
</form>
</div>
</div>
</div>
</div>
</div>
<div className="col-md-2" />
</div>
</div>
</div>
)
}
}
export default connect(
state => ({
formProfile: state.formProfile.formProfile,
user: state.user,
}),
dispatch => ({
initForm: () => {
dispatch(initProfileForm())
},
onHandleFormChange: event => {
dispatch(updateProfileFormData(event.target.name, event.target.value))
},
onHandleProfileFormSubmit: event => {
dispatch(handleProfileFormSubmit(event))
},
})
)(ProfileEdit)

View File

@ -47,7 +47,7 @@ export default connect(
}), }),
dispatch => ({ dispatch => ({
onHandleFormChange: event => { onHandleFormChange: event => {
dispatch(handleFormChange(event)) dispatch(handleFormChange(event.target.name, event.target.value))
}, },
onHandleUserFormSubmit: (event, formType) => { onHandleUserFormSubmit: (event, formType) => {
dispatch(handleUserFormSubmit(event, formType)) dispatch(handleUserFormSubmit(event, formType))

View File

@ -46,4 +46,23 @@ export default class MpwoApi {
.then(response => response.json()) .then(response => response.json())
.catch(error => error) .catch(error => error)
} }
static updateProfile(form) {
const request = new Request(`${apiUrl}auth/profile/edit`, {
method: 'POST',
headers: new Headers({
'Content-Type': 'application/json',
Authorization: `Bearer ${window.localStorage.getItem('authToken')}`,
}),
body: JSON.stringify({
first_name: form.firstName,
last_name: form.lastName,
bio: form.bio,
location: form.location,
birth_date: form.birthdate,
}),
})
return fetch(request)
.then(response => response.json())
.catch(error => error)
}
} }

View File

@ -4,32 +4,11 @@ import initial from './initial'
const formData = (state = initial.formData, action) => { const formData = (state = initial.formData, action) => {
switch (action.type) { switch (action.type) {
case 'UPDATE_FORMDATA_EMAIL': case 'UPDATE_USER_FORMDATA':
return { return {
formData: { formData: {
...state.formData, ...state.formData,
email: action.email [action.target]: action.value
},
}
case 'UPDATE_FORMDATA_USERNAME':
return {
formData: {
...state.formData,
username: action.username
},
}
case 'UPDATE_FORMDATA_PASSWORD':
return {
formData: {
...state.formData,
password: action.password
},
}
case 'UPDATE_FORMDATA_PASSWORD_CONF':
return {
formData: {
...state.formData,
passwordConf: action.passwordConf
}, },
} }
case 'PROFILE_SUCCESS': case 'PROFILE_SUCCESS':
@ -39,6 +18,33 @@ const formData = (state = initial.formData, action) => {
} }
} }
const formProfile = (state = initial.formProfile, action) => {
switch (action.type) {
case 'UPDATE_PROFILE_FORMDATA':
return {
formProfile: {
...state.formProfile,
[action.target]: action.value
},
}
case 'INIT_PROFILE_FORM':
return {
formProfile: {
...state.formProfile,
firstName: action.user.firstName,
lastName: action.user.lastName,
birthDate: action.user.birthDate,
location: action.user.location,
bio: action.user.bio,
},
}
case 'PROFILE_SUCCESS':
return initial.formProfile
default:
return state
}
}
const message = (state = initial.message, action) => { const message = (state = initial.message, action) => {
switch (action.type) { switch (action.type) {
case 'AUTH_ERROR': case 'AUTH_ERROR':
@ -73,14 +79,29 @@ const user = (state = initial.user, action) => {
case 'LOGOUT': case 'LOGOUT':
window.localStorage.removeItem('authToken') window.localStorage.removeItem('authToken')
return initial.user return initial.user
case 'PROFILE_SUCCESS': case 'PROFILE_SUCCESS':
return { return {
id: action.message.data.id, id: action.message.data.id,
username: action.message.data.username, username: action.message.data.username,
email: action.message.data.email, email: action.message.data.email,
isAdmin: action.message.data.admin, isAdmin: action.message.data.admin,
createdAt: action.message.data.created_at, createdAt: action.message.data.created_at,
isAuthenticated: true isAuthenticated: true,
firstName: action.message.data.first_name
? action.message.data.first_name
: '',
lastName: action.message.data.last_name
? action.message.data.last_name
: '',
bio: action.message.data.bio
? action.message.data.bio
: '',
location: action.message.data.location
? action.message.data.location
: '',
birthDate: action.message.data.birth_date
? action.message.data.birth_date
: '',
} }
default: default:
return state return state
@ -89,6 +110,7 @@ const user = (state = initial.user, action) => {
const reducers = combineReducers({ const reducers = combineReducers({
formData, formData,
formProfile,
message, message,
messages, messages,
user, user,

View File

@ -8,13 +8,27 @@ export default {
createdAt: '', createdAt: '',
isAdmin: false, isAdmin: false,
isAuthenticated: false, isAuthenticated: false,
firstName: '',
lastName: '',
bio: '',
location: '',
birthDate: ''
}, },
formData: { formData: {
formData: { formData: {
username: '', username: '',
email: '', email: '',
password: '', password: '',
passwordConf: '', passwordConf: '',
}
},
formProfile: {
formProfile: {
firstName: '',
lastName: '',
bio: '',
location: '',
birthDate: ''
} }
}, },
} }