login as Nick, cart empty
interested in the first album
click add to cart, qty 1
1 album in cart
added more albums in cart
django admin
add another first album to cart for friends
same # of albums in cart
the qty of first album increased by 1
Andy logged in and added an album to cart
database has Andy's selection as well
postgres shoppingItems table
Nick logged in again, found same # of albums in Cart as used to be.
He did not see albums in Andy's cart
#django modelfrom django.db import models
from django.urls import reverse
from django.contrib.auth.models import User
class Album(models.Model):
artist = models.CharField(max_length=50)
album_title = models.CharField(max_length=50)
genre = models.CharField(max_length=50)
album_logo = models.FileField()
date_posted = models.DateTimeField(auto_now_add=True)
author = models.ForeignKey(User, on_delete=models.CASCADE, default=1)
#form submitted without action redirect to detail
def get_absolute_url(self):
return reverse('music:detail', kwargs={'pk': self.pk})
#query album.objects.get(pk=1)
def __str__(self):
return self.album_title + ' - ' + self.artist
class ShoppingItem(models.Model):
shopper = models.ForeignKey(User, on_delete=models.CASCADE, default=1)
album = models.ForeignKey(Album, on_delete=models.CASCADE, blank=True, default=1)
quantity = models.PositiveIntegerField(blank=True, default=0)
def __str__(self):
return 'shopper ' + str(self.shopper) + ' - album ' + str(self.album) + ' - quantity '+str(self.quantity)
---------------------------------------------
#django/api/serializers
class ShoppingItemsSerializer(serializers.ModelSerializer):
class Meta:
model = ShoppingItem
fields = ('id', 'shopper', 'album', 'quantity')
------------------------------------
#django/api/urls
urlpatterns = [
path('shoppingItems/', apiview.ShoppingItemsList.as_view(), name='ShoppingItems'),
]
-------------------------------------
#django/api/apiview
from music.models import Album, Song, ShoppingItem
from music.api.serializers import MusicSerializer, UserSerializer, SongSerializer, PasswordSerializer, ShoppingItemsSerializer
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from django.http import Http404
from django.contrib.auth.models import User
from django.shortcuts import get_object_or_404
from rest_framework.decorators import authentication_classes, permission_classes
from django.contrib.auth.password_validation import validate_password
from django.core import exceptions
from django.forms.models import model_to_dict
class ShoppingItemsList(APIView):
def get(self, request, format=None):
#for privacy, only return shoppers items
shoppingItems = ShoppingItem.objects.filter(shopper=request.user)
serializer = ShoppingItemsSerializer(shoppingItems, many=True)
return Response(serializer.data)
def post(self, request, format=None):
#customer has item in cart
try:
revisitedItem = ShoppingItem.objects.get(shopper=request.user, album=request.data.get('album'))
revisitedItem.quantity= int(request.data.get('quantity')) + revisitedItem.quantity
serializer = ShoppingItemsSerializer(data=model_to_dict( revisitedItem ))
if serializer.is_valid():
# update database
revisitedItem.save()
return Response(serializer.data, status=status.HTTP_202_ACCEPTED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
#customer select new item
except exceptions.ObjectDoesNotExist:
serializer = ShoppingItemsSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
------------------------------------
//partials/flippyCard
import React, { Component } from 'react';
import '../App.css';
import { FaPenNib } from "react-icons/fa";
import { Button } from 'reactstrap';
import { MdEdit, MdDelete, MdFlip, MdAddShoppingCart, MdExposureNeg1, MdExposurePlus1 } from "react-icons/md";
import { Link } from 'react-router-dom';
import { Popconfirm, Popover, Input } from 'antd';
export default class FlippyCard extends Component {
constructor(props) {
super(props);
this.state = {
visible: false,
qty: 0,
qty_input: null,
};
this.myRef = React.createRef();
}
flipClick = () => {
this.myRef.current.classList.toggle('manu-flip');
console.log('Album: ', this.props.data.album_title, ' DOM class: ', this.myRef.current.classList);
}
hide = () => {
this.setState({
visible: false,
});
};
handleVisibleChange = visible => {
this.setState({ visible, qty_input: null, qty: 0 });
this.flipClick();
};
add = async () => {
await this.setState({ qty_input: null });
this.setState(prevState => { return { qty: prevState.qty + 1 } })
}
subtract = async () => {
await this.setState({ qty_input: null });
if (this.state.qty > 0) {
this.setState(prevState => { return { qty: prevState.qty - 1 } })
}
}
inputChange = (e) => {
this.setState({ qty_input: parseInt(e.target.value), qty: parseInt(e.target.value) })
}
render() {
const imgSrc = 'http://127.0.0.1:8000' + this.props.data.album_logo;
const detailLink = '/albumDetail/' + this.props.data.id;
const updateLink = '/updateAlbum/' + this.props.data.id;
return (
<div className="flip-container" ref={this.myRef} onTouchStart={() => this.flipClick()} style={{ margin: '10px' }}>
<div className="flipper">
<div className="front" style={{ backgroundColor: 'white' }}>
<img src={imgSrc}
style={{ height: '150px', width: '100%' }}></img>
<div style={{ height: '60px' }}>
<h6 style={{ textAlign: 'center', marginTop: '10px' }}>{this.props.data.album_title}</h6>
</div>
<div style={{ backgroundColor: '#F4F6F6', height: '30px', paddingLeft: '5px' }}>
<FaPenNib /> <span>{this.props.data.author}</span>
</div>
<MdFlip className='flip-button' onClick={() => this.flipClick()} />
</div>
<div className="back" style={{ backgroundColor: 'white' }}>
<div style={{ textAlign: 'center', height: '220px', paddingTop: '10px' }}>
<p>Artist: {this.props.data.artist}</p>
<p>Genre: {this.props.data.genre}</p>
<div style={{ display: 'flex', justifyContent: 'space-around', marginLeft: '5px', marginRight: '5px' }}>
<Link to={detailLink}><Button color='primary' size='sm'>View Detail</Button></Link>
<Link to={updateLink}><Button outline size='sm'><MdEdit /></Button></Link>
<Popconfirm
title="Are you sure to delete?"
onConfirm={() => this.props.confirmDelete(this.props.data.id)}
okText="Yes"
cancelText="No"
>
<Button outline size='sm'><MdDelete /></Button>
</Popconfirm>
</div>
</div>
<div style={{ backgroundColor: '#F4F6F6', height: '30px', paddingLeft: '5px' }}>
<FaPenNib /> <span>{this.props.data.date_posted.split('.')[0].replace('T', ' ')}</span>
</div>
<MdFlip className='flip-button' onClick={() => this.flipClick()} />
<Popover
content={
<div style={{ display: 'flex', justifyContent: 'space-around', backgroundColor: '#D5F5E3' }}>
<a onClick={() => this.props.addShoppingItem(this.props.data.id, this.state.qty)}>Add to Cart</a>
<span>|</span>
<a onClick={this.hide}>Cancel</a>
</div>
}
title={
<Input
type='number'
onChange={(e) => { this.inputChange(e) }}
style={{ width: '150px' }}
addonBefore={<MdExposureNeg1 onClick={() => this.subtract()} style={{ cursor: 'pointer', fontSize: '20px' }} />}
addonAfter={<MdExposurePlus1 onClick={() => this.add()} style={{ cursor: 'pointer', fontSize: '20px' }} />}
value={this.state.qty_input || this.state.qty}
/>
}
trigger="click"
visible={this.state.visible}
onVisibleChange={this.handleVisibleChange}
>
<MdAddShoppingCart className='shopping-cart' />
</Popover>
</div>
</div>
</div>
);
}
}
//pages/albums
import React, { Component } from 'react';
import '../App.css';
import { Redirect } from 'react-router-dom';
import { connect } from 'react-redux';
import FlippyCard from '../partials/flippyCard';
import { Row, Col } from 'reactstrap'
import { getAlbums } from '../redux/actions/getAlbums';
import { getUsers } from '../redux/actions/getUser';
import { getSongs } from '../redux/actions/getSongs';
import { deleteAlbum } from '../redux/actions/deleteAlbum';
import { postShoppingItem } from '../redux/actions/postShoppingItem';
import { getShoppingItems } from '../redux/actions/getShoppingIItems';
import { message } from 'antd';
import { Button } from 'reactstrap';
class Albums extends Component {
constructor(props) {
super(props);
this.state = {
AlbumAuthorData: [],
AlbumAuthorIntegrated: false,
postsPerPage: 4,
currentPage: 1,
};
}
componentDidMount() {
//start fetching database once logged in
if (this.props.loggedin) {
if (!this.props.gotUsers) {
this.props.dispatch(getUsers(this.props.token));
}
if (!this.props.gotAlbums) {
this.props.dispatch(getAlbums(this.props.token));
}
if (!this.props.gotSongs) {
this.props.dispatch(getSongs(this.props.token));
}
if (!this.props.gotShoppingItems) {
this.props.dispatch(getShoppingItems(this.props.token));
}
}
this.RefreshAlbum();
}
RefreshAlbum = () => {
//check every 1 second to see if albums are fetched. If so, start integrating Albums and Authors
const waitGetAlbums = setInterval(() => {
if (this.props.gotAlbums && this.props.gotUsers) {
let newAlbums = []
this.props.albums.map((album, index) => {
const newAlbum = {
'id': album.id,
'album_title': album.album_title,
'artist': album.artist,
'genre': album.genre,
'date_posted': album.date_posted,
'author': this.props.users.filter(author => author.id == album.author)[0].username,
'album_logo': album.album_logo
}
newAlbums.push(newAlbum);
});
this.setState({ AlbumAuthorData: newAlbums, AlbumAuthorIntegrated: true });
clearInterval(waitGetAlbums);
}
}, 100)
}
confirmDelete = (id) => {
this.props.dispatch(deleteAlbum(this.props.token, id));
//wait for 5sec, check every sec to see if delete successful
let i = 0;
const waitDelete = setInterval(() => {
if (this.props.albumDeleted) {
message.success('Album deleted.')
//refresh database
this.RefreshAlbum();
clearInterval(waitDelete);
}
if (i == 50) {
message.error(this.props.errorAlbum + ' timed out.')
clearInterval(waitDelete);
}
i++;
}, 100)
}
changePostPerPage = (e) => {
this.setState({ currentPage: 1, postsPerPage: parseInt(e.target.value) });
}
previousClick = () => {
this.setState((prevState) => {
const { currentPage } = prevState;
return { currentPage: currentPage <= 1 ? 1 : currentPage - 1 }
})
}
nextClick = () => {
this.setState((prevState) => {
const { currentPage, AlbumAuthorData, postsPerPage } = prevState;
return {
currentPage: currentPage * postsPerPage >= AlbumAuthorData.length ? currentPage : currentPage + 1
}
})
}
firstClick = () => {
this.setState({ currentPage: 1 })
}
lastClick = () => {
const { AlbumAuthorData, postsPerPage } = this.state;
const lastPage = parseInt(AlbumAuthorData.length / postsPerPage) + 1;
this.setState({ currentPage: lastPage })
}
pageClick = (page) => {
this.setState({ currentPage: page })
}
addShoppingItem = (album_id, qty) => {
if (qty === 0) { message.error('quantity entered 0'); return }
const formData = new FormData();
formData.set('shopper', this.props.users.filter(user => user.username === this.props.username)[0].id);
formData.set('album', album_id);
formData.set('quantity', qty);
this.props.dispatch(postShoppingItem(this.props.token, formData));
//wait for 5sec, check every sec to see if addShoppingItem successful
let i = 0;
const waitAdd = setInterval(() => {
if (this.props.shoppingItemAdded) {
message.success('Added ' + qty + ' ' + this.props.albums.filter(album => album.id === album_id)[0].album_title)
clearInterval(waitAdd);
}
if (i == 50) {
message.error('connection timed out.')
clearInterval(waitAdd);
}
i++;
}, 100)
}
render() {
if (!this.props.loggedin) {
return <Redirect to='/login' />
}
const cards = [];
if (this.state.AlbumAuthorIntegrated) {
this.state.AlbumAuthorData.map((item, index) => {
const card = <Col key={index}>
<FlippyCard data={item}
confirmDelete={(id) => this.confirmDelete(id)}
addShoppingItem={(id, qty) => this.addShoppingItem(id, qty)}></FlippyCard>
</Col>
cards.push(card);
})
}
const { currentPage, postsPerPage, AlbumAuthorData } = this.state;
const arrayStart = (currentPage - 1) * postsPerPage;
const arrayEnd = arrayStart + postsPerPage < cards.length ? arrayStart + postsPerPage : cards.length;
const cardsPagination = cards.slice(arrayStart, arrayEnd);
return (
<div style={{ marginTop: '20px' }}>
{this.props.errorAlbum} {this.props.errorSong} {this.props.errorUser}{this.props.errorShoppingItem}
{
this.state.AlbumAuthorIntegrated ?
<Row>
{cardsPagination}
</Row>
: <p>integrating authors and albums...</p>
}
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '10px' }}>
<Button onClick={() => this.firstClick()} color='secondary' style={{ marginRight: '5px' }} size='sm'>{'<<'}</Button>
<Button onClick={() => this.previousClick()} color='secondary' style={{ marginRight: '5px' }} size='sm'>{'<'}</Button>
{currentPage >= 3 ? <Button onClick={() => this.pageClick(currentPage - 2)} color='secondary' style={{ marginRight: '5px' }} size='sm'>{this.state.currentPage - 2}</Button> : null}
{currentPage >= 2 ? <Button onClick={() => this.pageClick(currentPage - 1)} color='secondary' style={{ marginRight: '5px' }} size='sm'>{this.state.currentPage - 1}</Button> : null}
<Button color='primary' style={{ marginRight: '5px' }} size='sm'>{this.state.currentPage}</Button>
{currentPage * postsPerPage < AlbumAuthorData.length ? <Button onClick={() => this.pageClick(currentPage + 1)} color='secondary' style={{ marginRight: '5px' }} size='sm'>{this.state.currentPage + 1}</Button> : null}
{(currentPage + 1) * postsPerPage < AlbumAuthorData.length ? <Button onClick={() => this.pageClick(currentPage + 2)} color='secondary' style={{ marginRight: '5px' }} size='sm'>{this.state.currentPage + 2}</Button> : null}
<Button onClick={() => this.nextClick()} color='secondary' style={{ marginRight: '5px' }} size='sm'>{'>'}</Button>
<Button onClick={() => this.lastClick()} color='secondary' style={{ marginRight: '5px' }} size='sm'>{'>>'}</Button>
<div style={{ display: 'flex' }}>
<select style={{ marginRight: '5px' }} onChange={(e) => this.changePostPerPage(e)}>
<option value={2}>2 Posts/Page</option>
<option value={3}>3 Posts/Page</option>
<option value={4} selected>4 Posts/Page</option>
<option value={6}>6 Posts/Page</option>
<option value={8}>8 Posts/Page</option>
<option value={10}>10 Posts/Page</option>
</select>
</div>
</div>
</div>
);
}
}
export default connect(
(store) => {
return {
token: store.login.token,
loggedin: store.login.fetched,
errorAlbum: store.albums.error,
errorSong: store.songs.error,
errorUser: store.users.error,
albums: store.albums.albums,
gotAlbums: store.albums.fetched,
songs: store.songs.songs,
gotSongs: store.songs.fetched,
users: store.users.users,
gotUsers: store.users.fetched,
albumDeleted: store.albums.deleted,
username: store.login.username,
errorShoppingItem: store.shoppingItems.error,
shoppingItemAdded: store.shoppingItems.updated,
gotShoppingItems: store.shoppingItems.fetched,
shoppingItems: store.shoppingItems.shoppingItems,
};
}
)(Albums);
------------------------------------
//partials/header
import React, { Component } from 'react';
import '../App.css';
import { Menu, Input, Badge } from 'antd';
import { Link } from 'react-router-dom'
import { FaCompactDisc, FaPlus, FaEdit, FaBarcode, FaUser, FaSearch, FaShoppingCart } from "react-icons/fa";
import { GiLoveSong } from "react-icons/gi";
import { IoIosLogOut, IoIosLogIn } from "react-icons/io";
import { connect } from 'react-redux';
import { reset } from '../redux/actions/logoutAction';
class Header extends Component {
constructor(props) {
super(props);
this.state = {
menu_mode: null,
current: 'albums',
};
}
componentWillMount() {
//change menu mode based on screen width
const mode = window.innerWidth > 434 ? "horizontal" : "inline";
this.setState({ menu_mode: mode });
}
handleClick = (e) => {
this.setState({
current: e.key,
});
}
logout = () => {
this.props.dispatch(reset())
}
render() {
return (
<div style={{ position: 'sticky', top: 0, width: '100%', zIndex: 2 }}>
<Menu
style={{ backgroundColor: '#fcefe5' }}
onClick={this.handleClick}
selectedKeys={[this.state.current]}
mode={this.state.menu_mode}
>
<Menu.Item key="home">
<Link to='/'><span className='Nav-Brand'>Django</span></Link>
</Menu.Item>
<Menu.SubMenu title={<span><FaCompactDisc /> Albums</span>}>
<Menu.Item key="albums">
<Link to='/'> <FaCompactDisc /> Get Albums</Link>
</Menu.Item>
<Menu.Item key="add">
<Link to='/addAlbum'> <FaPlus /> Add Album</Link>
</Menu.Item>
</Menu.SubMenu>
<Menu.Item key="songs">
<Link to='/'> <GiLoveSong /> Songs</Link>
</Menu.Item>
<Menu.Item key="search">
<Input type='text' size='small'
suffix={<FaSearch style={{ color: 'rgba(0,0,0,.25)', cursor: 'text' }} />}
placeholder='Search'
style={{ width: 150 }}></Input>
</Menu.Item>
{
this.props.loggedin ?
<Menu.Item key="shoppingCart" style={{ float: 'right' }}>
<Link to='/shoppingCart'>
<span style={{ fontSize: '20px' }}><FaShoppingCart /></span>
<Badge count={this.props.shoppingItems.length}></Badge></Link>
</Menu.Item>
:
null
}
{
this.props.loggedin ?
<Menu.SubMenu title={<span><FaUser /> {this.props.username}</span>} style={{ float: 'right' }}>
<Menu.Item key="0" onClick={() => this.logout()}>
<Link to='/logout'> <IoIosLogOut /> Logout</Link>
</Menu.Item>
<Menu.Item key="1">
<Link to='/'> <FaEdit /> Update Profile</Link>
</Menu.Item>
<Menu.Item key="2">
<Link to='/changePassword/'> <FaBarcode /> Change Password</Link>
</Menu.Item>
</Menu.SubMenu>
:
<Menu.Item key="login" style={{ float: 'right' }}>
<Link to='/login'> <IoIosLogIn /> Login</Link>
</Menu.Item>
}
</Menu>
</div>
);
}
}
export default connect(
(store) => {
return {
username: store.login.username,
token: store.login.token,
loggingin: store.login.fetching,
loggedin: store.login.fetched,
shoppingItems: store.shoppingItems.shoppingItems,
};
}
)(Header);
-------------------------------------
//redux/actions/getShoppingItems
import axios from 'axios';
export function getShoppingItems(token) {
return {
type: "fetch_shoppingItems",
payload: axios({
method: 'get',
url: 'http://127.0.0.1:8000/api/shoppingItems/',
headers: {
Authorization: 'Token ' + token
},
})
}
}
---------------------------------
//redux/postShoppingItem
import axios from 'axios';
export function postShoppingItem(token, data) {
return {
type: "add_update_shoppingItem",
payload: axios({
method: 'post',
url: 'http://127.0.0.1:8000/api/shoppingItems/',
headers: {
Authorization: 'Token ' + token,
},
data: data,
})
}
}
------------------------------------------
//redux/reducers/shoppingItemReducer
export default function reducer(
state = {
shoppingItems: [],
fetching: false,
fetched: false,
deleting: false,
deleted: false,
updating: false,
updated: false,
error: ''
},
action
) {
switch (action.type) {
case "fetch_shoppingItems_PENDING": {
return { ...state, fetching: true, fetched: false, error: '' }
}
case "fetch_shoppingItems_FULFILLED": {
return {
...state,
fetching: false,
fetched: true,
shoppingItems: action.payload.data,
error: ''
}
}
case "fetch_shoppingItems_REJECTED": {
return {
...state,
fetching: false,
error: JSON.stringify(action.payload.response.data),
}
}
case "delete_shoppingItem_PENDING": {
return { ...state, deleting: true, deleted: false, error: '' }
}
case "delete_shoppingItem_FULFILLED": {
const deleteId = action.payload.config.data;
return {
...state,
deleting: false,
deleted: true,
shoppingItems: [...state.shoppingItems].filter(item => item.id != deleteId),
error: ''
}
}
case "delete_shoppingItem_REJECTED": {
return {
...state,
deleting: false,
error: JSON.stringify(action.payload.response.data),
}
}
case "add_update_shoppingItem_PENDING": {
return { ...state, updating: true, updated: false, error: '' }
}
case "add_update_shoppingItem_FULFILLED": {
const data = action.payload.data;
//check if shopper has item in cart
const itemInCart = [...state.shoppingItems].filter(item => item.shopper == data.shopper && item.album == data.album)
//not in cart, create new
if (itemInCart.length === 0) {
return {
...state,
updating: false,
updated: true,
shoppingItems: [...state.shoppingItems].concat(data),
error: ''
}
}
//in cart, update
else {
const repurchasedItem = {
shopper: data.shopper,
album: data.album,
quantity: itemInCart[0].quantity + data.quantity,
}
return {
...state,
updating: false,
updated: true,
shoppingItems: [...state.shoppingItems].filter(item => item.album != data.album || item.shopper != data.shopper).concat(repurchasedItem),
error: ''
}
}
}
case "add_update_shoppingItem_REJECTED": {
return {
...state,
updating: false,
error: JSON.stringify(action.payload.response.data),
}
}
case "reset": {
return {
...state,
shoppingItems: [],
fetching: false,
fetched: false,
deleting: false,
deleted: false,
updating: false,
updated: false,
error: ''
}
}
default:
break;
}
return state;
}
reference:
QuerySet
https://docs.djangoproject.com/en/2.2/ref/models/querysets/
QueryDict
https://kite.com/python/docs/django.http.QueryDict
https://stackoverflow.com/questions/769843/how-do-i-use-and-in-a-django-filter
https://stackoverflow.com/questions/21925671/convert-django-model-object-to-dict-with-all-of-the-fields-intact
https://stackoverflow.com/questions/26672077/django-model-vs-model-objects-create
https://stackoverflow.com/questions/3805958/how-to-delete-a-record-in-django-models
https://stackoverflow.com/questions/21128899/how-do-i-make-an-auto-increment-integer-field-in-django
https://stackoverflow.com/questions/2712682/django-update-object
https://stackoverflow.com/questions/757022/how-do-you-serialize-a-model-instance-in-django
No comments:
Post a Comment