A series of talks covering the “life cycle” of a project.
“Show me the money!”
Writing the server-side code.
Install a virtualenv with django.
$ mkvirtualenv mymovie-server
$ pip install django
$ django-admin startproject mymovie
Add a requirements.txt with the project dependancies, and
$ pip install -r requirements.txt
PyMySQL==0.6.6 # Python3 compatible library
psycopg2==2.6 # add Postgresql for Heroku
Django==1.8.2
djangorestframework==3.1.2 # for API
djangorestframework-jwt==1.2.0 # for API authentication
drf-nested-routers==0.9.0
jsonfield==1.0.3 # for ease of use
django-filter==0.10.0
python-social-auth==0.2.10 # social authentication
Pillow==2.8.1
django-resized==0.3.5
sorl-thumbnail==12.2
django-redis==4.0.0
hiredis==0.2.0
django-compressor==1.5
# for serving the site in most places
waitress==0.8.9
whitenoise==1.0.6
dj-database-url==0.3.0
# settings.development
from .base import *
DEBUG = True
TEMPLATE_DEBUG = True
INSTALLED_APPS += ("debug_toolbar",)
INTERNAL_IPS = ("127.0.0.1",)
MIDDLEWARE_CLASSES += ("debug_toolbar.middleware.DebugToolbarMiddleware",)
ALLOWED_HOSTS = ['*']
# settings.base snippet
def get_env_variable(var_name, default=None):
"""
Get the environment variable or return the default if provided
or raise an exception
"""
try:
if os.environ[var_name] in ('true', 'false'):
return bool(strtobool(os.environ[var_name]))
return os.environ[var_name]
except KeyError:
if default is not None:
return default
error_msg = "Set the %s environment variable" % var_name
raise ImproperlyConfigured(error_msg)
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'mymovie',
'USER': get_env_variable('DB_USER', ''),
'PASSWORD': get_env_variable('DB_PASSWORD', ''),
'HOST': get_env_variable('DB_HOST', ''),
'PORT': get_env_variable('DB_PORT', ''),
'OPTIONS': {'init_command': 'SET storage_engine=INNODB;'},
}
}
# Use the STATIC_HOST env to setup CDN hosting
# http://whitenoise.evans.io/en/latest/django.html#instructions-for-amazon-cloudfront
STATIC_URL = get_env_variable('DJANGO_STATIC_HOST', '') + '/static/'
MEDIA_URL = '/media/'
# Logging
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s'
},
},
'handlers': {
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
},
'loggers': {
# requests handled by django
'django.requests': {
'handlers': ['console'],
'level': get_env_variable('DJANGO_LOG_LEVEL', 'ERROR'),
}
}
}
.
├── app
│ ├── __init__.py
│ ├── settings
│ │ ├── base.py
│ │ ├── development.py
│ │ ├── __init__.py
│ │ ├── production.py
│ │ └── test.py
│ ├── static
│ │ └── app
│ │ ├── favicon.png
│ │ └── robots.txt
│ ├── tests
│ │ ├── __init__.py
│ │ ├── test_static.py
│ │ └── test_validate.py
│ ├── urls.py
│ └── wsgi.py
├── LICENSE
├── manage.py
├── Procfile.dev
├── README.md
├── requirements
│ ├── base.txt
│ ├── develop.txt
│ ├── production.txt
│ └── test.txt
└── requirements.txt
# -*- coding: utf-8 -*-
from calendar import timegm
from django.contrib.auth import get_user_model
from django.test import TestCase
from mock import patch
from rest_framework import status
from rest_framework.test import APIClient
from rest_framework_jwt import utils
from .factories import UserFactory
class TokenTestCase(TestCase):
"""Methods from the JWT tests to work with tokens."""
def create_token(self, user, exp=None, orig_iat=None):
payload = utils.jwt_payload_handler(user)
if exp:
payload['exp'] = exp
if orig_iat:
payload['orig_iat'] = timegm(orig_iat.utctimetuple())
token = utils.jwt_encode_handler(payload)
return token
class TestUserApi(TokenTestCase):
"""Test the API calls."""
def setUp(self):
self.client = APIClient(enforce_csrf_checks=True)
def test_login(self):
"""Test the login endpoint"""
user = get_user_model().objects.create_user(
username='testuser',
email='testuser@test.domain.com',
password='password',
)
data = dict(
username=user.username,
email=user.email,
password='password',
)
response = self.client.post(
'/api/v1/auth/login',
data=data,
format='json',
)
self.assertEqual(response.status_code, status.HTTP_200_OK,
'Got error: {}'.format(response.content))
decoded_payload = utils.jwt_decode_handler(response.data['token'])
self.assertEqual(decoded_payload['username'], user.username)
self.assertEqual(decoded_payload['email'], user.email)
self.assertEqual(decoded_payload['user_id'], user.pk)
def test_get_info(self):
"""Call the get info endpoint"""
user = UserFactory.create() # suppress @UndefinedVariable
auth = 'JWT {}'.format(self.create_token(user))
response = self.client.get(
'/api/v1/users/me',
HTTP_AUTHORIZATION=auth,
format='json',
)
self.assertEqual(response.status_code, status.HTTP_200_OK,
'Got error: {}'.format(response.content))
self.assertEqual(response.data['email'], user.email)
self.assertEqual(response.data['id'], user.pk)
self.assertEqual(response.data['first_name'], user.first_name)
self.assertEqual(response.data['last_name'], user.last_name)
self.assertEqual(response.data['full_name'], user.get_full_name())
self.assertEqual(response.data['avatar'], user.profile.avatar_url)
gplus_user_data = {...mock data...}
class TestSocialAuth(TestCase):
"""Test that the user is registered if we get a social access token."""
backend = 'google-plus'
access_token = 'sample.token'
def setUp(self):
self.client = APIClient(enforce_csrf_checks=True)
@patch('social.backends.base.BaseAuth.get_json')
def test_gplus_auth(self, mock):
"""Exchange access token for JWT"""
mock.return_value = gplus_user_data
response = self.client.get(
'/api/v1/auth/register-by-token/{}'.format(self.backend),
params=dict(
access_token=self.access_token
),
format='json',
)
self.assertEqual(response.status_code, status.HTTP_200_OK,
'Got error: {}'.format(response.content))
self.assertIn('token', response.data, 'Missing JWT')
self.assertIn('user', response.data, 'Missing User')
# -*- coding: utf-8 -*-
import datetime
from django.conf import settings
from django.db import models
from django.utils.encoding import python_2_unicode_compatible
from jsonfield import JSONField
from movies.service import omdb
SERVICE_OMDB = 'omdb'
SERVICE_CHOICES = (
(SERVICE_OMDB, 'Open Movie Database'),
)
class MovieManager(models.Manager):
def lookup(self, name=None, service=SERVICE_OMDB, service_id=None):
"""
Lookup a movie.
If we don't have it already, retrieve data from the service.
"""
movie = None
qs = self.filter(
servicemovie__service=service,
servicemovie__service_id=service_id)
if qs.exists():
return qs[0]
else:
movie_data = omdb.get(service_id=service_id)
if movie_data:
movie, _ = self.get_or_create(
name=movie_data.get('Title', name),
year=movie_data.get('Year'),
poster=movie_data.get('Poster'),
)
service, _ = ServiceMovie.objects.get_or_create(
movie=movie,
service=service,
service_id=service_id,
)
service.service_data = movie_data
service.updated = datetime.date.today()
service.save()
return movie
@python_2_unicode_compatible
class Movie(models.Model):
"""Minimal amount of data for a Movie."""
name = models.CharField(max_length=255, db_index=True)
poster = models.URLField(max_length=255, blank=True, null=True)
year = models.CharField(
max_length=20,
verbose_name='Year of Release',
db_index=True, blank=True, null=True)
subscribers = models.ManyToManyField(settings.AUTH_USER_MODEL,
through='Watchlist')
class Meta:
ordering = ('name',)
def __str__(self):
return '{0}'.format(self.name)
@python_2_unicode_compatible
class ServiceMovie(models.Model):
"""Link between a movie and a remote service with more information."""
movie = models.ForeignKey(Movie)
service_id = models.CharField(max_length=50, db_index=True)
service = models.CharField(max_length=10, db_index=True,
choices=SERVICE_CHOICES, default=SERVICE_OMDB)
service_data = JSONField(null=True, blank=True)
updated = models.DateField(null=True, blank=True, db_index=True)
class Meta:
unique_together = ('service_id', 'service')
def __str__(self):
return '{0}'.format(self.movie.name)
@python_2_unicode_compatible
class Watchlist(models.Model):
"""
Link between a movie and a user.
The Notifications requested are linked to this model.
"""
movie = models.ForeignKey(Movie)
user = models.ForeignKey(settings.AUTH_USER_MODEL)
def __str__(self):
return '{0}/{1}'.format(self.movie.name, self.user.get_full_name())
NOTIFY_CINEMA = 0
NOTIFY_RETAIL = 1
NOTIFY_RENTAL = 2
NOTIFY_STREAMING = 3
NOTIFICATION_CHOICES = (
(NOTIFY_CINEMA, 'Cinema'),
(NOTIFY_RETAIL, 'Retail Purchase'),
(NOTIFY_RENTAL, 'Rental'),
(NOTIFY_STREAMING, 'Online Streaming'),
)
@python_2_unicode_compatible
class Notification(models.Model):
"""
A list of the notifications a user has requested.
"""
watchlist = models.ForeignKey(Watchlist)
notified = models.BooleanField(default=False)
notified_date = models.DateField(
null=True, blank=True,
help_text='Date when the notification was sent',
)
type = models.IntegerField(
db_index=True,
choices=NOTIFICATION_CHOICES, default=NOTIFY_CINEMA,
help_text='Send notification when movie is available for ...'
)
class Meta:
ordering = ('watchlist', 'type')
unique_together = ('watchlist', 'type')
def __str__(self):
return '{0}/{1}/{2}/{3}'.format(
self.get_type_display(),
'Y' if self.notified else 'N',
self.watchlist.movie.name,
self.watchlist.user.get_full_name(),
)
# -*- coding: utf-8 -*-
import requests
APIHOST = 'http://www.omdbapi.com/'
def get(name=None, service_id=None):
params = dict(type='movie',
r='json')
if name:
params.update(dict(s=name))
if id:
params.update(dict(i=service_id, tomatoes=True))
response = requests.get(APIHOST, params=params)
data = response.json()
if 'Error' in data and data.get('Response') == 'False':
raise Exception(data.get('Error'))
if response.ok:
return data
else:
return None
# -*- coding: utf-8 -*-
from rest_framework import serializers
from .models import Movie, Watchlist, Notification, ServiceMovie
from users.serializers import UserSerializer
class ServiceSerializer(serializers.ModelSerializer):
class Meta:
model = ServiceMovie
fields = ('id', 'service', 'service_id', 'updated', 'service_data')
class MovieSerializer(serializers.ModelSerializer):
services = ServiceSerializer(many=True, source='servicemovie_set')
class Meta:
model = Movie
fields = ('id', 'name', 'poster', 'year', 'services')
{
"id": 1,
"name": "Movie 0",
"poster": "http://www.kautzer.net/",
"year": "2015",
"services": [
{
"id": 1,
"service": "omdb",
"service_id": "8651",
"updated": "2015-05-07",
"service_data": "{'jsonfield': 'Sample data'}"
}
]
}
class WatchlistSerializer(serializers.ModelSerializer):
user = UserSerializer(write_only=True, required=False)
movie = MovieSerializer(read_only=True)
notifications = NotificationSerializer(
read_only=True,
many=True,
source='notification_set')
# fields used when creating a new watchlist
moviename = serializers.CharField(write_only=True)
notifywhen = serializers.ListField(write_only=True)
service = serializers.CharField(write_only=True)
service_id = serializers.CharField(write_only=True)
class Meta:
model = Watchlist
def create(self, validated_data):
"""
Save the new watchlist and associated notifications and movies.
"""
ModelClass = self.Meta.model
moviename = validated_data.pop('moviename')
notifywhen = validated_data.pop('notifywhen')
service = validated_data.pop('service')
service_id = validated_data.pop('service_id')
movie = Movie.objects.lookup(name=moviename,
service=service,
service_id=service_id,
)
validated_data.update(dict(movie=movie))
try:
instance = ModelClass.objects.create(**validated_data)
except TypeError as exc:
msg = (
'Got a `TypeError` when calling `%s.objects.create()`. '
'This may be because you have a writable field on the '
'serializer class that is not a valid argument to '
'`%s.objects.create()`. You may need to make the field '
'read-only, or override the %s.create() method to handle '
'this correctly.\nOriginal exception text was: %s.' %
(
ModelClass.__name__,
ModelClass.__name__,
self.__class__.__name__,
exc
)
)
raise TypeError(msg)
for notify_type in notifywhen:
Notification.objects.create(watchlist=instance, type=notify_type)
return instance
When you POST
{
"moviename": "Star Trek",
"service": "omdb",
"service_id": "tt0796366",
"notifywhen": [ "0", "1" ]
}
You get
{
"id": 3,
"movie": {
"id": 4,
"name": "Star Trek",
"poster": "http://ia.media-imdb.com/images/M/MV5BMjE5NDQ5OTE4Ml5BMl5BanBnXkFtZTcwOTE3NDIzMw@@._V1_SX300.jpg",
"year": "2009",
"services": [
{
"id": 4,
"service": "omdb",
"service_id": "tt0796366",
"updated": "2015-06-17",
"service_data": "some stuff goes here"
}
]
},
"notifications": [
{
"id": 4,
"notified": false,
"notified_date": null,
"type": 0,
"watchlist": 3
},
{
"id": 5,
"notified": false,
"notified_date": null,
"type": 1,
"watchlist": 3
}
]
}
# -*- coding: utf-8 -*-
from rest_framework import viewsets
from custom_rest_framework.viewsets import JWTViewSet
from .models import Movie, Watchlist, Notification
from .serializers import MovieSerializer, \
WatchlistSerializer, \
NotificationSerializer
class MovieViewSet(JWTViewSet, viewsets.ModelViewSet):
serializer_class = MovieSerializer
queryset = Movie.objects.all()
class WatchlistViewSet(JWTViewSet, viewsets.ModelViewSet):
serializer_class = WatchlistSerializer
def get_queryset(self):
"""Filter based on the auth user"""
user = self.request.user
return Watchlist.objects.filter(user=user)
def perform_create(self, serializer):
"""Ensure we save the request user with the model"""
serializer.save(user=self.request.user)
class NotificationViewSet(JWTViewSet, viewsets.ModelViewSet):
serializer_class = NotificationSerializer
def get_queryset(self):
"""Filter based on the auth user"""
user = self.request.user
return Notification.objects.filter(watchlist__user=user)
# -*- coding: utf-8 -*-
from django.conf.urls import patterns, include, url
from rest_framework_nested.routers import SimpleRouter, NestedSimpleRouter
from .views import MovieViewSet, WatchlistViewSet, NotificationViewSet
router = SimpleRouter(trailing_slash=False)
router.include_format_suffixes = False
router.register(r'v1/movies', MovieViewSet, base_name='movie')
router.register(r'v1/watchlists', WatchlistViewSet, base_name='watchlist')
# register the nested urls for movie routes
wl_router = NestedSimpleRouter(router, r'v1/watchlists',
lookup='watchlist', trailing_slash=False)
wl_router.register(r'notifications', NotificationViewSet,
base_name='notification')
urlpatterns = patterns('',
url(r'', include(router.urls, namespace='movie')),
url(r'', include(wl_router.urls, namespace='movie')),
)
class TestMovieApi(TokenTestCase):
"""Test the API calls."""
def setUp(self):
self.client = APIClient(enforce_csrf_checks=True)
self.user = UserFactory.create() # suppress @UndefinedVariable
self.auth = 'JWT {}'.format(self.create_token(self.user))
def test_get_movie(self):
movie = MovieFactory.create() # suppress @UndefinedVariable
response = self.client.get(
'/api/v1/movies/{}'.format(movie.pk),
HTTP_AUTHORIZATION=self.auth,
format='json',
)
self.assertEqual(response.status_code, status.HTTP_200_OK,
'Got error: {}'.format(response.content))
self.assertEqual(response.data['id'], movie.pk)
def test_get_watchlist_wrong_user(self):
watchlist = WatchlistFactory.create() # suppress @UndefinedVariable
response = self.client.get(
'/api/v1/watchlists/{}'.format(watchlist.pk),
HTTP_AUTHORIZATION=self.auth,
format='json',
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND,
'Got error: {}'.format(response.content))
def test_get_watchlist(self):
watchlist = WatchlistWithNotificationsFactory.create() # suppress @UndefinedVariable
response = self.client.get(
'/api/v1/watchlists/{}'.format(watchlist.pk),
HTTP_AUTHORIZATION='JWT {}'.format(
self.create_token(watchlist.user)
),
format='json',
)
self.assertEqual(response.status_code, status.HTTP_200_OK,
'Got error: {}'.format(response.content))
self.assertEqual(response.data['id'], watchlist.pk)
def test_create_watchlist(self):
"""Create a new watchlist"""
data = dict(
moviename="Star Trek",
service=SERVICE_OMDB,
service_id='tt0796366',
notifywhen=[NOTIFY_CINEMA, NOTIFY_RENTAL, NOTIFY_STREAMING],
)
response = self.client.post(
'/api/v1/watchlists',
data=data,
HTTP_AUTHORIZATION=self.auth,
format='json',
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED,
'Got error: {}'.format(response.content))
self.assertIn('id', response.data, 'Missing id')
self.assertIn('movie', response.data, 'Missing movie')
self.assertIn('notifications', response.data, 'Missing notifications')
.
├── apiary.apib
├── app
│ ├── __init__.py
│ ├── __pycache__
│ ├── settings
│ │ ├── base.py
│ │ ├── development.py
│ │ ├── heroku.py
│ │ ├── __init__.py
│ │ ├── production.py
│ │ ├── __pycache__
│ │ └── test.py
│ ├── static
│ │ └── app
│ │ ├── favicon.png
│ │ ├── logo.png
│ │ ├── logo.svg
│ │ └── robots.txt
│ ├── templates
│ │ └── app
│ │ └── coming_soon.html
│ ├── tests
│ │ ├── __init__.py
│ │ ├── __pycache__
│ │ ├── test_static.py
│ │ └── test_validate.py
│ ├── urls.py
│ ├── views.py
│ └── wsgi.py
├── custom_rest_framework
│ ├── __init__.py
│ ├── __pycache__
│ └── viewsets.py
├── LICENSE
├── manage.py
├── movies
│ ├── admin.py
│ ├── __init__.py
│ ├── migrations
│ │ ├── 0001_initial.py
│ │ ├── 0002_auto_20150614_1740.py
│ │ ├── 0003_auto_20150615_1633.py
│ │ ├── __init__.py
│ │ └── __pycache__
│ ├── models.py
│ ├── __pycache__
│ ├── serializers.py
│ ├── service
│ │ ├── __init__.py
│ │ ├── omdb.py
│ │ └── __pycache__
│ ├── tests
│ │ ├── factories.py
│ │ ├── __init__.py
│ │ ├── __pycache__
│ │ ├── test_admin_actions.py
│ │ ├── test_api.py
│ │ └── test_models.py
│ ├── urls.py
│ └── views.py
├── Procfile
├── Procfile.dev
├── README.md
├── requirements
│ ├── base.txt
│ ├── develop.txt
│ ├── production.txt
│ ├── stackato.txt
│ └── test.txt
├── requirements.txt
├── runtime.txt
├── users
│ ├── admin.py
│ ├── __init__.py
│ ├── migrations
│ │ ├── 0001_initial.py
│ │ ├── __init__.py
│ │ └── __pycache__
│ ├── models.py
│ ├── __pycache__
│ ├── serializers.py
│ ├── social.py
│ ├── static
│ │ └── users
│ │ └── images
│ │ └── avatar-default.png
│ ├── tests
│ │ ├── factories.py
│ │ ├── __init__.py
│ │ ├── __pycache__
│ │ ├── test_api.py
│ │ ├── test_models.py
│ │ └── test_static.py
│ ├── urls.py
│ └── views.py
└── utils
├── actions.py
├── admin.py
├── __init__.py
├── migrations
│ └── __init__.py
├── models.py
├── __pycache__
├── tests.py
└── views.py
<insert question here>