By Emmanuelle Delescolle
Slides at http://bit.ly/djember2
Code at http://bit.ly/djember_commits
Before you start, you should have completed part 1 of this workshop
import random
from factory import fuzzy, SubFactory, Faker, lazy_attribute
from factory.django import DjangoModelFactory
from ..models import Category, Product
class CategoryFactory(DjangoModelFactory):
class Meta:
model = Category
name = fuzzy.FuzzyText(
prefix='Category ',
length=3
)
LOREM_CATEGORIES = [
'food',
'animals',
'technics',
]
back/catalog/tests/factories.py
class ProductFactory(DjangoModelFactory):
class Meta:
model = Product
name = Faker('word')
category = SubFactory(CategoryFactory)
description = Faker('text')
image = lazy_attribute(
lambda o: 'http://lorempixel.com/200/300/{}'.format(
random.choice(LOREM_CATEGORIES)
))
price = fuzzy.FuzzyDecimal(5)
from .factories import ProductFactory, CategoryFactory
from my_webshop.tests.base import BaseAPITestCase
from rest_framework.test import APITestCase
from ..models import Product, Category
from ..serializers import ProductSerializer, CategorySerializer
class CategoryAPITest(BaseAPITestCase, APITestCase):
api_base_name = 'category'
model = Category
model_factory_class = CategoryFactory
serializer_class = CategorySerializer
api_is_read_only = True
class ProductAPITest(BaseAPITestCase, APITestCase):
api_base_name = 'product'
model = Product
model_factory_class = ProductFactory
serializer_class = ProductSerializer
api_is_read_only = True
back/catalog/tests/test_api.py
$ ./manage.py test
Creating test database for alias 'default'...
...............
----------------------------------------------------------------------
Ran 15 tests in 0.201s
OK
Destroying test database for alias 'default'...
In your shell (in back)
Let's fix that!
...
moduleForModel('category', 'Unit | Model | category', {
// Specify the other units that are required for this test.
needs: ['model:product']
});
...
front/tests/unit/models/category-test.js
...
moduleForModel('product', 'Unit | Model | product', {
// Specify the other units that are required for this test.
needs: ['model:category']
});
...
front/tests/unit/models/product-test.js
import FactoryGuy from 'ember-data-factory-guy';
FactoryGuy.define('category', {
sequences: {
categoryName: (num) => {
return `Category ${num}`;
}
},
default: {
name: FactoryGuy.generate('categoryName')
}
});
front/tests/factories/category.js
$ ember g factory category
In your shell (in front)
import FactoryGuy from 'ember-data-factory-guy';
import { faker } from 'ember-cli-mirage';
FactoryGuy.define('product', {
sequences: {
productName: (num) => {
return `Product ${num}`;
}
},
default: {
name: FactoryGuy.generate('productName'),
category: FactoryGuy.belongsTo('category'),
description: faker.lorem.paragraph,
image: faker.image.imageUrl,
price: faker.commerce.price
}
});
front/tests/factories/product.js
$ ember g factory product
In your shell (in front)
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
import { manualSetup, make } from 'ember-data-factory-guy';
moduleForComponent('searchable-products', 'Integration | Component | searchable products', {
integration: true,
beforeEach: function() {
manualSetup(this.container);
}
});
test('it renders', function(assert) {
const model = [];
for(let i=0; i<2; i++) {
const c = make('category');
const ct = Math.floor((Math.random * 2) + 2);
for(let j=0; j<ct; j++) {
model.push(make('product', { category: c }));
}
}
this.set('model', model);
this.render(hbs`{{searchable-products model=model}}`);
assert.equal(this.$().text().trim(), '');
});
front/tests/integration/components/searchable-products/component-test.js
import { contains } from '../../../helpers/sl/synchronous';
...
test('it renders', function(assert) {
let model = [];
...
assert.equal(this.$().text().trim(), '');
const found = make('product',{name: 'Find me'});
const hidden = make('product', {name: 'Hide me'});
model = [found, hidden];
this.set('model', model);
this.render(hbs`{{searchable-products model=model searchTerm='fiNd'}}`);
assert.ok(contains(this.$().text(), 'Find me'));
assert.notOk(contains(this.$().text(), 'Hide me'));
});
front/tests/integration/components/searchable-products/component-test.js
import Ember from 'ember';
export default Ember.Service.extend({
items: Ember.A(),
append(product, qty) {
const match = this.get('items').findBy('product', product);
if (match) {
Ember.set(match, 'qty', match.qty + qty);
} else {
this.get('items').pushObject({ product, qty });
}
},
count: Ember.computed('items', 'items.length', 'items.@each.qty', function() {
return this.get('items').reduce(function(sum, item) {
return sum + items.qty;
}, 0);
})
});
$ ember g service cart
In your shell (in front)
front/app/services/cart.js
which in turn uses our brand new service
...
export default Ember.Route.extend(ApplicationRouteMixin, {
authenticator: 'authenticator:django',
cart: Ember.inject.service(),
model() {
return sections;
},
actions: {
addToCart(product) {
this.get('cart').append(product, 1);
},
...
front/app/application/route.js
...
{{model.description}}
<br/>
<button class="btn btn-primary" {{action 'addToCart' model}}>Buy</button>
</div>
front/app/product/template.hbs
which in turn uses our brand new service
import Ember from 'ember';
export default Ember.Component.extend({
cart: Ember.inject.service()
});
front/app/components/shopping-cart/template.hbs
$ ember g component shopping-cart --pod
In your shell (in front)
<i class="fa fa-shopping-basket fa-2x"></i>
{{#if cart.items.length}}
<span class="badge">{{cart.count}}</span>
{{/if}}
front/app/components/shopping-cart/component.js
& {
margin: $grid-gutter-width / 2;
display: flex;
flex-direction: row;
> span.badge {
align-self: flex-start;
background-color: $brand-warning;
margin-left: - $grid-gutter-width / 4;
}
}
front/app/components/shopping-cart/styles.scss
{{#if session.isAuthenticated}}
<span class="user-info">Welcome {{session.currentUser.username}}</span>
{{shopping-cart}}
<button class="btn btn-danger" {{action 'invalidate' on='click'}}>
<i class="fa fa-sign-out"></i>Â Â Sign out
</button>
{{else}}
{{shopping-cart}}
<div class="dropdown login-dropdown">
...
front/app/components/user-info/template.hbs
...
> div {
float: left;
}
}
front/app/components/user-info/styles.scss
Our component doesn't implement "block usage", let's not test for it
...
// REMOVE those lines
// // Template block usage:
// this.render(hbs`
// {{#shopping-cart}}
// template block text
// {{/shopping-cart}}
// `);
// assert.equal(this.$().text().trim(), 'template block text');
});
front/tests/integration/components/shopping-cart/component-test.js
<h2>Your basket</h2>
{{# if model.count}}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr><th colspan="2">Item</th><th>Qty</th>
<th>Ttl price</th></tr>
</thead>
<tbody>
{{#each model.items as |item|}}
<tr>
<td class="tn-col">
<img src="{{item.product.image}}">
</td>
<td>{{item.product.name}}</td>
<td class="qty-col">{{item.qty}}</td>
<td class="price-col"></td>
</tr>
{{/each}}
</tbody>
<tfoot>
<tr>
<th colspan="3">Total</th>
<th class="price-col">$</th>
</tr>
</tfoot>
</table>
</div>
{{else}}
<div class="alert alert-warning">
<i class="fa fa-warning"></i>
Your basket is empty
</div>
{{/if}}
front/app/basket/template.hbs
$ ember g route basket --pod
In your shell (in front)
...
@import "site";
@import "../basket/styles";
@import "pod-styles";
front/app/styles/app.scss
{{#link-to 'basket'}}<i class="fa fa-shopping-basket fa-2x"></i>{{/link-to}}
...
front/app/components/shopping-cart/template.hbs
front/app/components/shopping-cart/styles.scss
& {
a {
color: $text-color;
}
...
.table-responsive {
width: 90vw;
table {
width: 100%;
}
.qty-col, .price-col {
text-align: right;
}
.tn-col > img {
max-width: 200px;
max-height: 150px;
}
}
front/app/basket/styles.scss
front/app/basket/route.js
import Ember from 'ember';
export default Ember.Route.extend({
cart: Ember.inject.service(),
model() {
return this.get('cart');
}
});
<td class="price-col">
{{number-format
(mult item.product.price item.qty)
decimals=2
}}
</td>
front/app/basket/template.hbs
$ ember install ember-math-helpers
$ ember install ember-string-helpers
In your shell (in front)
<th class="price-col">$ {{number-format model.total decimals=2}}</th>
front/app/basket/template.hbs
...
}),
total: Ember.computed('count', function() {
return this.get('items').reduce(function(sum, item) {
return sum + item.qty * item.product.get('price');
}, 0);
})
});
front/app/services/cart.js
...
actions: {
removeFromCart(product) {
this.get('cart').remove(product);
},
addToCart(product) {
...
front/app/application/route.js
...
}),
remove(product) {
this.set('items', this.get('items').rejectBy('product', product));
}
});
front/app/services/cart.js
...
<thead>
<tr><th colspan="2">Item</th><th>Qty</th><th>Ttl price</th><th></th></tr>
</thead>
...
<td>
<a {{action 'removeFromCart' item.product}}>
<i class="text-danger fa fa-remove"></i>
</a>
</td>
</tr>
{{/each}}
...
<th class="price-col" colspan="2">
$ {{number-format model.total decimals=2}}
</th>
</tr>
</tfoot>
...
front/app/basket/template.hbs
you now have a fully functional basket!
Let's start with the Django side
...
INSTALLED_APPS = (
...
'checkout',
)
...
back/settings.py
from django.db import models
from catalog.models import Product
class Order(models.Model):
name = models.CharField(max_length=300)
phone = models.CharField(max_length=20)
created = models.DateTimeField(auto_now_add=True)
class OrderLine(models.Model):
order = models.ForeignKey(Order, related_name='lines')
product = models.ForeignKey(Product)
qty = models.PositiveIntegerField()
back/checkout/models.py
$ ./manage.py startapp checkout
In your shell (in back)
In your shell (in back)
$ ./manage.py makemigrations
$ ./manage.py migrate
from rest_framework import serializers
from .models import Order, OrderLine
class OrderLineSerializer(serializers.ModelSerializer):
class Meta:
model = OrderLine
fields = ('id', 'product', 'qty', )
class OrderSerializer(serializers.ModelSerializer):
lines = OrderLineSerializer(many=True)
class Meta:
model = Order
fields = ('id', 'name', 'phone', 'lines', )
def create(self, validated_data):
lines = validated_data.pop('lines')
instance = Order.objects.create(**validated_data)
for order_line in lines:
instance.lines.create(**order_line)
return instance
back/checkout/serializers.py
from rest_framework import viewsets, mixins
from rest_framework.permissions import AllowAny
from .models import Order
from .serializers import OrderSerializer
class OrderViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet):
serializer_class = OrderSerializer
queryset = Order.objects.all()
permission_classes = [AllowAny, ]
back/checkout/views.py
...
from checkout.views import OrderViewSet
...
router.register(r'orders', OrderViewSet)
urlpatterns = [
...
back/my_webshop/api_urls.py
$ ember g model order
$ ember g model orderline
In your shell (in front)
On the Ember side
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { hasMany } from 'ember-data/relationships';
export default Model.extend({
name: attr('string'),
phone: attr('string'),
lines: hasMany('orderline')
});
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { belongsTo } from 'ember-data/relationships';
export default Model.extend({
product: belongsTo('product', {
async: true,
}),
qty: attr('number')
});
front/app/models/orderline.js
front/app/models/order.js
$ ember g serializer order
In your shell (in front)
A custom serializer in Ember as well
import ApplicationSerializer from '../application/serializer';
import EmbeddedRecordsMixin from 'ember-data/serializers/embedded-records-mixin';
export default ApplicationSerializer.extend(EmbeddedRecordsMixin, {
attrs: {
lines: { embedded: 'always' }
}
});
front/app/serializers/order.js
$ ember g route order --pod
$ ember install ember-cli-flash
In your shell (in front)
import Ember from 'ember';
export default Ember.Route.extend({
cart: Ember.inject.service(),
flashMessages: Ember.inject.service(),
model() { return this.get('cart'); },
actions: { saveOrder(name, phone) {
const record = this.get('store').createRecord('order', { name, phone });
const lines = record.get('lines');
this.get('cart.items').forEach((line) => {
lines.pushObject(
this.get('store').createRecord('orderline', {
product: line.product, qty: line.qty })
);
});
record.save().then(() => {
this.get('flashMessages').success('Thank you for your order');
}).catch(() => {
this.get('flashMessages').danger('something went wrong!');
});
}}
});
front/app/order/route.js
<h2>Checkout</h2>
{{#if model.count}}
<form class="form-horizontal">
<h3>Personal information</h3>
<div class="form-group">
<label for="name" class="col-sm-3 control-label">Name</label>
<div class="col-sm-9">
<input type="text" class="form-control" id="name"
onChange={{action (mut name) value='target.value'}}>
</div></div>
<div class="form-group">
<label for="phone" class="col-sm-3 control-label">Phone</label>
<div class="col-sm-9">
<input type="phone" class="form-control" id="phone"
onChange={{action (mut phone) value='target.value'}}>
</div></div>
<div class="form-group">
<div class="pull-right">
<button class="btn btn-primary" {{action 'saveOrder' name phone}}>Order</button>
</div></div>
</form>
{{else}}
<div class="alert alert-warning">
<i class="fa fa-warning"></i> Your basket is empty
</div>
{{/if}}
front/app/order/template.hbs
...
<main class="container">
{{#each flashMessages.queue as |flash|}}
{{flash-message flash=flash messageStyle='boostrap'}}
{{/each}}
{{ember-load-remover}}
{{liquid-outlet}}
</main>
...
front/app/application/template.hbs
Displaying flash messages
And a link to the order route
...
</table>
</div>
<div class="pull-right">
<a class="btn btn-primary" href="{{href-to 'order'}}">Confirm</a>
</div>
{{else}}
...
front/app/basket/template.hbs
Your customers may now order!
...
}),
clear() {
this.set('items', Ember.A());
}
});
front/app/services/cart.js
...
record.save().then(() => {
this.transitionTo('index');
this.get('cart').clear();
this.get('flashMessages')
.success('Thank you for your order');
}).catch(
...
front/app/order/route.js
...
this.transition(
this.fromRoute('basket'),
this.toRoute('order'),
this.use('toLeft'),
this.reverse('toRight')
);
this.transition(
this.toRoute('basket'),
this.use('toDown')
);
this.transition(
this.fromRoute('order'),
this.use('toUp')
);
this.transition(
this.use('fade', { duration: 150 }),
this.debug()
);
}
front/app/transitions.js
...
<div class="form-group">
<div class="pull-left">
<a class="btn btn-default" href="{{href-to 'basket'}}">Cancel</a>
</div>
<div class="pull-right">
<button class="btn btn-primary" {{action 'saveOrder' name phone}}>Order</button>
</div>
</div>
</form>
{{else}}
...
front/app/order/template.hbs
...
this.transitionTo('index');
Ember.run.later(()=> {
this.get('cart').clear();
}, 300);
this.get('flashMessages').success('Thank you for your order');
...
front/app/order/route.js
...
class Order(models.Model):
...
@property
def total(self):
return sum([line.product.price * line.qty for line in self.lines.all()])
@property
def title(self):
return 'Order #{}'.format(self.id)
def __str__(self):
return '{} (${})'.format(self.title, self.total)
class OrderLine(models.Model):
...
def __str__(self):
return '{} X {}'.format(self.qty, self.product.name)
back/checkout/models.py
First let's improve our models
from django.contrib import admin
from .models import Order, OrderLine
class OrderLineInline(admin.TabularInline):
model = OrderLine
extra = 0
fields = ('qty', 'product', )
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ['title', 'total', ]
inlines = [OrderLineInline, ]
def get_queryset(self, request):
return super(OrderAdmin, self).get_queryset(request) \
.prefetch_related('lines', 'lines__product')
back/checkout/admin.py
...
needs: ['model:product']
...
front/tests/unit/models/orderline-test.js
...
needs: ['serializer:order', 'model:orderline']
...
front/test/unit/serializers/order-test.js
...
needs: ['model:orderline']
...
front/tests/unit/models/order-test.js