From 06a700925c0655b9643b0a6929e9aba7d8bbd20e Mon Sep 17 00:00:00 2001 From: Robin Schroer Date: Thu, 27 Oct 2016 15:46:55 +0100 Subject: [PATCH 1/8] Code test --- .dockerignore | 2 + .gitignore | 3 + Dockerfile | 9 ++ README.md | 59 ++++++++++++ Vagrantfile | 18 ++++ docker-compose.yml | 18 ++++ manage.py | 22 +++++ manapy | 3 + requirements.in | 3 + scripts/runserver | 10 ++ scripts/setup-server-user.sh | 33 +++++++ scripts/setup-server.sh | 15 +++ shiptrader/__init__.py | 0 shiptrader/admin.py | 3 + shiptrader/migrations/0001_initial.py | 43 +++++++++ shiptrader/migrations/__init__.py | 0 shiptrader/models.py | 19 ++++ shiptrader/tests.py | 3 + shiptrader/views.py | 3 + testsite/__init__.py | 0 testsite/settings.py | 129 ++++++++++++++++++++++++++ testsite/urls.py | 21 +++++ testsite/wsgi.py | 16 ++++ 23 files changed, 432 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 Vagrantfile create mode 100644 docker-compose.yml create mode 100755 manage.py create mode 100755 manapy create mode 100644 requirements.in create mode 100755 scripts/runserver create mode 100755 scripts/setup-server-user.sh create mode 100755 scripts/setup-server.sh create mode 100644 shiptrader/__init__.py create mode 100644 shiptrader/admin.py create mode 100644 shiptrader/migrations/0001_initial.py create mode 100644 shiptrader/migrations/__init__.py create mode 100644 shiptrader/models.py create mode 100644 shiptrader/tests.py create mode 100644 shiptrader/views.py create mode 100644 testsite/__init__.py create mode 100644 testsite/settings.py create mode 100644 testsite/urls.py create mode 100644 testsite/wsgi.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ccca24a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +.git +*.pyc \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3f6532 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.pyc +.vagrant +*.log \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5457092 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM alpine:3.6 + +RUN apk add --update py3-pip python3 postgresql postgresql-dev zlib-dev libjpeg-turbo-dev gcc python3-dev musl-dev make \ + && pip3 install --upgrade pip +RUN if [[ ! -e /usr/bin/python ]]; then ln -sf /usr/bin/python3 /usr/bin/python; fi + +WORKDIR /srv/python-code-test +ADD requirements.in /srv/python-code-test/ +RUN pip3 install -r requirements.in \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a83e86e --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Ostmodern Python Code Test + +The goal of this exercise is to test that you know your way around Django and +REST APIs. Approach it the way you would an actual long-term project. + +The idea is to build a platform on which your users can buy and sell Starships. +To make this process more transparent, it has been decided to source some +technical information about the Starships on sale from the [Starship +API](https://swapi.co/documentation#starships). + +A Django project some initial data models have been created already. You may need +to do some additional data modelling to satify the requirements. + +## Getting started + +* This test works with either + [Docker](https://docs.docker.com/compose/install/#install-compose) or + [Vagrant](https://www.vagrantup.com/downloads.html) +* Get the code from `https://github.com/ostmodern/python-code-test` +* Do all your work in your own `develop` branch +* Once you have downloaded the code the following commands will get the site up + and running + +```shell +# For Docker +docker-compose up +# You can run `manage.py` commands using the `./manapy` wrapper + +# For Vagrant +vagrant up +vagrant ssh +# Inside the box +./manage.py runserver 0.0.0.0:8008 +``` +* The default Django "It worked!" page should now be available at + http://localhost:8008/ + +## Tasks + +Your task is to build a JSON-based REST API for your frontend developers to +consume. You have built a list of user stories with your colleagues, but you get +to decide how to design the API. Remember that the frontend developers will need +some documentation of your API to understand how to use it. + +We do not need you to implement users or authentication, to reduce the amount of +time this exercise will take to complete. You may use any external libraries you +require. + +* We need to be able to import all existing + [Starships](https://swapi.co/documentation#starships) to the provided Starship + Model +* A potential buyer can browse all Starships +* A potential buyer can browse all the listings for a given `starship_class` +* A potential buyer can sort listings by price or time of listing +* To list a Starship as for sale, the user should supply the Starship name and + list price +* A seller can deactivate and reactivate their listing + +After you are done, create a release branch in your repo and send us the link. diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 0000000..6fab5ed --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,18 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +# Vagrantfile API/syntax version. Don't touch unless you know what you're doing! +VAGRANTFILE_API_VERSION = "2" + +Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| + config.vm.box = "ubuntu/xenial64" + + # Django runserver networking + config.vm.network "forwarded_port", guest: 8008, host: 8008 + + config.vm.provision "shell", path:"./scripts/setup-server.sh" + + config.vm.provider :virtualbox do |vb, override| + vb.memory = 512 + end +end diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0513217 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +version: "3" +services: + code-test: + build: + context: . + command: "scripts/runserver" + volumes: + - .:/srv/python-code-test + ports: + - "8008:8008" + links: + - postgresql + postgresql: + image: postgres:9.6 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..b99c032 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testsite.settings") + try: + from django.core.management import execute_from_command_line + except ImportError: + # The above import may fail for some other reason. Ensure that the + # issue is really that Django is missing to avoid masking other + # exceptions on Python 2. + try: + import django + except ImportError: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) + raise + execute_from_command_line(sys.argv) diff --git a/manapy b/manapy new file mode 100755 index 0000000..b667b52 --- /dev/null +++ b/manapy @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-compose run --rm code-test ./manage.py $* diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..13869b0 --- /dev/null +++ b/requirements.in @@ -0,0 +1,3 @@ +django>=1.11,<2.0 +Pillow>=3.4.1,<3.5 +psycopg2>=2.6.2,<2.7 diff --git a/scripts/runserver b/scripts/runserver new file mode 100755 index 0000000..e53be2b --- /dev/null +++ b/scripts/runserver @@ -0,0 +1,10 @@ +#!/bin/sh + +until PGPASSWORD=postgres psql --host postgresql --username postgres -c '\l' > /dev/null; do + echo "Postgres is unavailable - sleeping" + sleep 1 +done + +export PYTHONUNBUFFERED=0 +./manage.py migrate +./manage.py runserver 0.0.0.0:8008 diff --git a/scripts/setup-server-user.sh b/scripts/setup-server-user.sh new file mode 100755 index 0000000..3215a14 --- /dev/null +++ b/scripts/setup-server-user.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# VirtualEnv and Django setup + +USER=ubuntu + +# Set up virtualenv directory for the user if required +if [ ! -d /home/$USER/.virtualenvs ]; then + mkdir /home/$USER/.virtualenvs +fi + +# write all the profile stuff for the user if required +grep -q virtualenvs /home/$USER/.bashrc +if [ $? -ne 0 ]; then + echo -e "\033[0;31m > Updating profile file\033[0m" + echo "source ~/.virtualenvs/code-test/bin/activate" >> /home/$USER/.bashrc + echo "cd /vagrant/" >> /home/$USER/.bashrc +fi + +echo -e "\033[0;34m > Setting up virtualenv\033[0m" +export WORKON_HOME=/home/$USER/.virtualenvs +export PIP_VIRTUALENV_BASE=/home/$USER/.virtualenvs +python3 -m venv $PIP_VIRTUALENV_BASE/code-test +source $PIP_VIRTUALENV_BASE/code-test/bin/activate + +# install requirements +echo -e "\033[0;34m > Installing the pip requirements.\033[0m" +$PIP_VIRTUALENV_BASE/code-test/bin/pip install -U pip +$PIP_VIRTUALENV_BASE/code-test/bin/pip install wheel==0.29.0 +$PIP_VIRTUALENV_BASE/code-test/bin/pip install -r /vagrant/requirements.in + +# setup db state +cd /vagrant +./manage.py migrate diff --git a/scripts/setup-server.sh b/scripts/setup-server.sh new file mode 100755 index 0000000..c9cbdc9 --- /dev/null +++ b/scripts/setup-server.sh @@ -0,0 +1,15 @@ +#!/bin/bash +USER=ubuntu + +apt-get update +apt-get install -y git vim build-essential python3.5-dev python3-venv \ + libncurses5-dev fabric postgresql-9.5 postgresql-server-dev-9.5 \ + libjpeg62-dev zlib1g-dev libfreetype6-dev + +sudo -u postgres psql -c "CREATE DATABASE postgres ENCODING='UTF8' TEMPLATE=template0;" +sudo -u postgres psql -c "CREATE USER ubuntu;" +sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE postgres TO ubuntu;" +sudo -u postgres psql -c "ALTER USER ubuntu CREATEDB;" + +chmod +x /vagrant/scripts/setup-server-user.sh +sudo -H -u $USER /vagrant/scripts/setup-server-user.sh diff --git a/shiptrader/__init__.py b/shiptrader/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shiptrader/admin.py b/shiptrader/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/shiptrader/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/shiptrader/migrations/0001_initial.py b/shiptrader/migrations/0001_initial.py new file mode 100644 index 0000000..5a3bfc5 --- /dev/null +++ b/shiptrader/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.9 on 2018-01-16 13:15 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Listing', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('price', models.IntegerField()), + ], + ), + migrations.CreateModel( + name='Starship', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('starship_class', models.CharField(max_length=255)), + ('manufacturer', models.CharField(max_length=255)), + ('length', models.FloatField()), + ('hyperdrive_rating', models.FloatField()), + ('cargo_capacity', models.BigIntegerField()), + ('crew', models.IntegerField()), + ('passengers', models.IntegerField()), + ], + ), + migrations.AddField( + model_name='listing', + name='ship_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='listings', to='shiptrader.Starship'), + ), + ] diff --git a/shiptrader/migrations/__init__.py b/shiptrader/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shiptrader/models.py b/shiptrader/models.py new file mode 100644 index 0000000..7fc3377 --- /dev/null +++ b/shiptrader/models.py @@ -0,0 +1,19 @@ +from django.db import models + + +class Starship(models.Model): + starship_class = models.CharField(max_length=255) + manufacturer = models.CharField(max_length=255) + + length = models.FloatField() + hyperdrive_rating = models.FloatField() + cargo_capacity = models.BigIntegerField() + + crew = models.IntegerField() + passengers = models.IntegerField() + + +class Listing(models.Model): + name = models.CharField(max_length=255) + ship_type = models.ForeignKey(Starship, related_name='listings') + price = models.IntegerField() diff --git a/shiptrader/tests.py b/shiptrader/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/shiptrader/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/shiptrader/views.py b/shiptrader/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/shiptrader/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/testsite/__init__.py b/testsite/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/testsite/settings.py b/testsite/settings.py new file mode 100644 index 0000000..549b890 --- /dev/null +++ b/testsite/settings.py @@ -0,0 +1,129 @@ +""" +Django settings for testsite project. + +Generated by 'django-admin startproject' using Django 1.11.8. + +For more information on this file, see +https://docs.djangoproject.com/en/1.11/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.11/ref/settings/ +""" + +import os + +DOCKER = os.getenv('USER') != 'ubuntu' + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'a-z$8$66fyjy01^328r$5nmj=bzz2a8m%^-kf403!(ohrg5k_b' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'shiptrader', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'testsite.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'testsite.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/1.11/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': 'postgres', + } +} + +if DOCKER: + DATABASES['default']['NAME'] = 'postgres' + DATABASES['default']['HOST'] = 'postgresql' + DATABASES['default']['USER'] = 'postgres' + DATABASES['default']['PASSWORD'] = 'postgres' + + +# Password validation +# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/1.11/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.11/howto/static-files/ + +STATIC_URL = '/static/' diff --git a/testsite/urls.py b/testsite/urls.py new file mode 100644 index 0000000..0bd1a31 --- /dev/null +++ b/testsite/urls.py @@ -0,0 +1,21 @@ +"""testsite URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.11/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.conf.urls import url, include + 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) +""" +from django.conf.urls import url +from django.contrib import admin + +urlpatterns = [ + url(r'^admin/', admin.site.urls), +] diff --git a/testsite/wsgi.py b/testsite/wsgi.py new file mode 100644 index 0000000..a65c11b --- /dev/null +++ b/testsite/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for testsite project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testsite.settings") + +application = get_wsgi_application() From 72e25e809e509bd5e48c4d54410e3348a7314ac9 Mon Sep 17 00:00:00 2001 From: Ian Brechin Date: Wed, 15 May 2019 17:33:49 +0100 Subject: [PATCH 2/8] Allow for null numeric fields on starships In some cases these values are unknown. --- shiptrader/admin.py | 7 +++- .../migrations/0002_auto_20190515_1626.py | 40 +++++++++++++++++++ shiptrader/models.py | 10 ++--- 3 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 shiptrader/migrations/0002_auto_20190515_1626.py diff --git a/shiptrader/admin.py b/shiptrader/admin.py index 8c38f3f..2062118 100644 --- a/shiptrader/admin.py +++ b/shiptrader/admin.py @@ -1,3 +1,8 @@ from django.contrib import admin -# Register your models here. +from shiptrader.models import Starship + + +@admin.register(Starship) +class StarshipAdmin(admin.ModelAdmin): + list_display = ('starship_class', 'manufacturer') diff --git a/shiptrader/migrations/0002_auto_20190515_1626.py b/shiptrader/migrations/0002_auto_20190515_1626.py new file mode 100644 index 0000000..e7c526f --- /dev/null +++ b/shiptrader/migrations/0002_auto_20190515_1626.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.20 on 2019-05-15 16:26 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shiptrader', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='starship', + name='cargo_capacity', + field=models.BigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='starship', + name='crew', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='starship', + name='hyperdrive_rating', + field=models.FloatField(blank=True, null=True), + ), + migrations.AlterField( + model_name='starship', + name='length', + field=models.FloatField(blank=True, null=True), + ), + migrations.AlterField( + model_name='starship', + name='passengers', + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/shiptrader/models.py b/shiptrader/models.py index 7fc3377..e894f3a 100644 --- a/shiptrader/models.py +++ b/shiptrader/models.py @@ -5,12 +5,12 @@ class Starship(models.Model): starship_class = models.CharField(max_length=255) manufacturer = models.CharField(max_length=255) - length = models.FloatField() - hyperdrive_rating = models.FloatField() - cargo_capacity = models.BigIntegerField() + length = models.FloatField(blank=True, null=True) + hyperdrive_rating = models.FloatField(blank=True, null=True) + cargo_capacity = models.BigIntegerField(blank=True, null=True) - crew = models.IntegerField() - passengers = models.IntegerField() + crew = models.IntegerField(blank=True, null=True) + passengers = models.IntegerField(blank=True, null=True) class Listing(models.Model): From 627b7dcc3666c6f1a899802db176a58dabfed1fb Mon Sep 17 00:00:00 2001 From: Ian Brechin Date: Wed, 15 May 2019 17:45:43 +0100 Subject: [PATCH 3/8] Add command to import starship data from swapi.co --- requirements.in | 3 + scripts/runserver | 1 + shiptrader/management/__init__.py | 0 shiptrader/management/commands/__init__.py | 0 .../management/commands/load_starships.py | 48 +++++++ shiptrader/tests.py | 3 - shiptrader/tests/__init__.py | 0 shiptrader/tests/test_commands.py | 131 ++++++++++++++++++ testsite/settings.py | 2 + 9 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 shiptrader/management/__init__.py create mode 100644 shiptrader/management/commands/__init__.py create mode 100644 shiptrader/management/commands/load_starships.py delete mode 100644 shiptrader/tests.py create mode 100644 shiptrader/tests/__init__.py create mode 100644 shiptrader/tests/test_commands.py diff --git a/requirements.in b/requirements.in index 13869b0..40535ce 100644 --- a/requirements.in +++ b/requirements.in @@ -1,3 +1,6 @@ django>=1.11,<2.0 Pillow>=3.4.1,<3.5 psycopg2>=2.6.2,<2.7 +djangorestframework>=3.9.4,<3.10 +requests>=2.21.0,<2.22 +responses>=0.10.6,<0.11 diff --git a/scripts/runserver b/scripts/runserver index e53be2b..cb5f69b 100755 --- a/scripts/runserver +++ b/scripts/runserver @@ -7,4 +7,5 @@ done export PYTHONUNBUFFERED=0 ./manage.py migrate +./manage.py load_starships --replace ./manage.py runserver 0.0.0.0:8008 diff --git a/shiptrader/management/__init__.py b/shiptrader/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shiptrader/management/commands/__init__.py b/shiptrader/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shiptrader/management/commands/load_starships.py b/shiptrader/management/commands/load_starships.py new file mode 100644 index 0000000..a87140e --- /dev/null +++ b/shiptrader/management/commands/load_starships.py @@ -0,0 +1,48 @@ +from django.conf import settings +from django.core.management import BaseCommand, CommandError +import requests + +from shiptrader.models import Starship + + +class Command(BaseCommand): + help = 'Loads all starship records from swapi.co' + + def add_arguments(self, parser): + parser.add_argument('--replace', action='store_true', + help='Delete any existing records before loading') + + def handle(self, *args, **options): + if options['replace']: + Starship.objects.all().delete() + + def clean_number(value): + if value in ('unknown', 'n/a'): + return None + return value.replace(',', '') + + path = settings.STARSHIP_API_PATH + while path is not None: + response = requests.get(path) + if response.status_code != 200: + raise CommandError('{} returned a response with status {}'.format( + path, response.status_code + )) + content = response.json() + if content['count'] == 0: + raise CommandError('No starships available for import') + new_starships = [] + for starship in content['results']: + new_starships.append( + Starship( + starship_class=starship['starship_class'], + manufacturer=starship['manufacturer'], + length=clean_number(starship['length']), + hyperdrive_rating=clean_number(starship['hyperdrive_rating']), + cargo_capacity=clean_number(starship['cargo_capacity']), + crew=clean_number(starship['crew']), + passengers=clean_number(starship['passengers']), + ) + ) + Starship.objects.bulk_create(new_starships) + path = content['next'] diff --git a/shiptrader/tests.py b/shiptrader/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/shiptrader/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/shiptrader/tests/__init__.py b/shiptrader/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shiptrader/tests/test_commands.py b/shiptrader/tests/test_commands.py new file mode 100644 index 0000000..52042a1 --- /dev/null +++ b/shiptrader/tests/test_commands.py @@ -0,0 +1,131 @@ +from django.conf import settings +from django.core.management import call_command, CommandError +from django.test import TestCase +import responses + +from shiptrader.models import Starship + +test_data = [ + { + 'count': 2, + 'next': 'https://swapi.co/api/starships/?page=2', + 'previous': None, + 'results': [ + { + 'name': 'Executor', + 'model': 'Executor-class star dreadnought', + 'manufacturer': 'Kuat Drive Yards,Fondor Shipyards', + 'cost_in_credits': '1143350000', + 'length': '19000', + 'max_atmosphering_speed': 'n/a', + 'crew': '279144', + 'passengers': '38000', + 'cargo_capacity': '250000000', + 'consumables': '6 years', + 'hyperdrive_rating': '2.0', + 'MGLT': '40', + 'starship_class': 'Star dreadnought', + 'pilots': [], + 'films': [ + 'https://swapi.co/api/films/2/', + 'https://swapi.co/api/films/3/' + ], + 'created': '2014-12-15T12:31:42.547000Z', + 'edited': '2017-04-19T10:56:06.685592Z', + 'url': 'https://swapi.co/api/starships/15/' + }, + ] + }, + { + 'count': 2, + 'next': None, + 'previous': None, + 'results': [ + { + "name": "Sentinel-class landing craft", + "model": "Sentinel-class landing craft", + "manufacturer": "Sienar Fleet Systems,Cyngus Spaceworks", + "cost_in_credits": "240000", + "length": "38", + "max_atmosphering_speed": "1000", + "crew": "5", + "passengers": "75", + "cargo_capacity": "180000", + "consumables": "1 month", + "hyperdrive_rating": "1.0", + "MGLT": "70", + "starship_class": "landing craft", + "pilots": [], + "films": [ + "https://swapi.co/api/films/1/" + ], + "created": "2014-12-10T15:48:00.586000Z", + "edited": "2014-12-22T17:35:44.431407Z", + "url": "https://swapi.co/api/starships/5/" + }, + ] + } +] + + +class LoadStarshipsTestCase(TestCase): + + def test_starship_import(self): + with responses.RequestsMock() as rsps: + rsps.add( + rsps.GET, + settings.STARSHIP_API_PATH, + json=test_data[0], + match_querystring=True + ) + rsps.add( + rsps.GET, + settings.STARSHIP_API_PATH + '?page=2', + json=test_data[1], + match_querystring=True + ) + call_command('load_starships') + self.assertEqual(Starship.objects.count(), 2) + + def test_starship_import_unspecified_values(self): + altered_test_data = test_data.copy() + altered_test_data[0]['results'][0]['cargo_capacity'] = 'unknown' + altered_test_data[1]['results'][0]['passengers'] = 'n/a' + + with responses.RequestsMock() as rsps: + rsps.add( + rsps.GET, + settings.STARSHIP_API_PATH, + json=altered_test_data[0], + match_querystring=True + ) + rsps.add( + rsps.GET, + settings.STARSHIP_API_PATH + '?page=2', + json=altered_test_data[1], + match_querystring=True + ) + call_command('load_starships') + self.assertEqual(Starship.objects.count(), 2) + self.assertIsNone( + Starship.objects.all()[0].cargo_capacity + ) + self.assertIsNone( + Starship.objects.all()[1].passengers + ) + + def test_errors_for_empty(self): + with responses.RequestsMock() as rsps: + rsps.add( + rsps.GET, + settings.STARSHIP_API_PATH, + json={ + 'count': 0, + 'next': None, + 'previous': None, + 'results': [] + }, + match_querystring=True + ) + with self.assertRaises(CommandError): + call_command('load_starships') diff --git a/testsite/settings.py b/testsite/settings.py index 549b890..162cbd8 100644 --- a/testsite/settings.py +++ b/testsite/settings.py @@ -127,3 +127,5 @@ # https://docs.djangoproject.com/en/1.11/howto/static-files/ STATIC_URL = '/static/' + +STARSHIP_API_PATH = 'https://swapi.co/api/starships/' From 000e8c4c2c3f3c847da4b954640e905e298ed573 Mon Sep 17 00:00:00 2001 From: Ian Brechin Date: Wed, 15 May 2019 20:58:42 +0100 Subject: [PATCH 4/8] Add API endpoint for retrieving starships --- shiptrader/serializers.py | 10 ++++++++ shiptrader/tests/test_views.py | 43 ++++++++++++++++++++++++++++++++++ shiptrader/urls.py | 12 ++++++++++ shiptrader/views.py | 12 ++++++++-- testsite/settings.py | 18 ++++++++++++++ testsite/urls.py | 3 ++- 6 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 shiptrader/serializers.py create mode 100644 shiptrader/tests/test_views.py create mode 100644 shiptrader/urls.py diff --git a/shiptrader/serializers.py b/shiptrader/serializers.py new file mode 100644 index 0000000..61ee674 --- /dev/null +++ b/shiptrader/serializers.py @@ -0,0 +1,10 @@ +from rest_framework import serializers + +from shiptrader.models import Starship + + +class StarshipSerializer(serializers.ModelSerializer): + + class Meta: + model = Starship + fields = '__all__' diff --git a/shiptrader/tests/test_views.py b/shiptrader/tests/test_views.py new file mode 100644 index 0000000..a0b1ff1 --- /dev/null +++ b/shiptrader/tests/test_views.py @@ -0,0 +1,43 @@ +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from shiptrader.models import Starship + + +class StarshipTestCase(APITestCase): + + def setUp(self): + Starship( + starship_class='cruiser', + manufacturer='SpaceX', + length=12.5, + hyperdrive_rating=1.3, + cargo_capacity=1800, + crew=3, + passengers=4, + ).save() + Starship( + starship_class='battleship', + manufacturer='BlueOrigin', + length=5.5, + hyperdrive_rating=5.0, + cargo_capacity=500, + crew=1, + passengers=2, + ).save() + + def test_get_starship_list(self): + response = self.client.get( + reverse('starship-list'), format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 2) + + def test_get_starship_detail(self): + pk = Starship.objects.first().id + response = self.client.get( + reverse('starship-detail', kwargs={'pk': pk}), format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['starship_class'], 'cruiser') \ No newline at end of file diff --git a/shiptrader/urls.py b/shiptrader/urls.py new file mode 100644 index 0000000..c67e03a --- /dev/null +++ b/shiptrader/urls.py @@ -0,0 +1,12 @@ +from django.conf.urls import url, include + +from rest_framework import routers + +from . import views + +starship_router = routers.DefaultRouter() +starship_router.register(r'starships', views.StarshipView) + +urlpatterns = [ + url(r'^', include(starship_router.urls)), +] diff --git a/shiptrader/views.py b/shiptrader/views.py index 91ea44a..c7b9050 100644 --- a/shiptrader/views.py +++ b/shiptrader/views.py @@ -1,3 +1,11 @@ -from django.shortcuts import render +from rest_framework import mixins, viewsets -# Create your views here. +from shiptrader.serializers import StarshipSerializer +from shiptrader.models import Starship + + +class StarshipView( + mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet +): + queryset = Starship.objects.all().order_by('pk') + serializer_class = StarshipSerializer diff --git a/testsite/settings.py b/testsite/settings.py index 162cbd8..844f297 100644 --- a/testsite/settings.py +++ b/testsite/settings.py @@ -40,6 +40,7 @@ 'django.contrib.messages', 'django.contrib.staticfiles', 'shiptrader', + 'rest_framework', ] MIDDLEWARE = [ @@ -129,3 +130,20 @@ STATIC_URL = '/static/' STARSHIP_API_PATH = 'https://swapi.co/api/starships/' + +REST_FRAMEWORK = { + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 20 +} + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': os.environ.get('DB_NAME', 'shiptrader'), + 'USER': os.environ.get('DB_USERNAME', 'postgres'), + 'PASSWORD': os.environ.get('DB_PASSWORD', 'postgres'), + 'HOST': os.environ.get('DB_HOST', 'localhost'), + 'PORT': os.environ.get('DB_PORT', ''), + } +} + diff --git a/testsite/urls.py b/testsite/urls.py index 0bd1a31..3cde729 100644 --- a/testsite/urls.py +++ b/testsite/urls.py @@ -13,9 +13,10 @@ 1. Import the include() function: from django.conf.urls import url, include 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) """ -from django.conf.urls import url +from django.conf.urls import url, include from django.contrib import admin urlpatterns = [ url(r'^admin/', admin.site.urls), + url(r'^api/', include('shiptrader.urls')), ] From 04b8c1967f08e56502f4197902cd4fdb29007eb2 Mon Sep 17 00:00:00 2001 From: Ian Brechin Date: Wed, 15 May 2019 21:45:07 +0100 Subject: [PATCH 5/8] Add views for clients to create and list listings Can order listings by date and price, and filter by starship class --- requirements.in | 1 + .../migrations/0003_auto_20190515_2029.py | 26 ++++ shiptrader/models.py | 3 + shiptrader/serializers.py | 10 +- shiptrader/tests/test_views.py | 127 +++++++++++++++--- shiptrader/urls.py | 7 +- shiptrader/views.py | 35 ++++- testsite/settings.py | 1 + 8 files changed, 184 insertions(+), 26 deletions(-) create mode 100644 shiptrader/migrations/0003_auto_20190515_2029.py diff --git a/requirements.in b/requirements.in index 40535ce..7c3599d 100644 --- a/requirements.in +++ b/requirements.in @@ -4,3 +4,4 @@ psycopg2>=2.6.2,<2.7 djangorestframework>=3.9.4,<3.10 requests>=2.21.0,<2.22 responses>=0.10.6,<0.11 +django-filter>=2.1.0,<2.2 diff --git a/shiptrader/migrations/0003_auto_20190515_2029.py b/shiptrader/migrations/0003_auto_20190515_2029.py new file mode 100644 index 0000000..1a94540 --- /dev/null +++ b/shiptrader/migrations/0003_auto_20190515_2029.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.20 on 2019-05-15 20:29 +from __future__ import unicode_literals + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shiptrader', '0002_auto_20190515_1626'), + ] + + operations = [ + migrations.AddField( + model_name='listing', + name='active', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='listing', + name='last_listed', + field=models.DateTimeField(default=datetime.datetime.now), + ), + ] diff --git a/shiptrader/models.py b/shiptrader/models.py index e894f3a..69539cc 100644 --- a/shiptrader/models.py +++ b/shiptrader/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.utils.timezone import now class Starship(models.Model): @@ -17,3 +18,5 @@ class Listing(models.Model): name = models.CharField(max_length=255) ship_type = models.ForeignKey(Starship, related_name='listings') price = models.IntegerField() + active = models.BooleanField(default=True) + last_listed = models.DateTimeField(default=now) diff --git a/shiptrader/serializers.py b/shiptrader/serializers.py index 61ee674..5ba6d21 100644 --- a/shiptrader/serializers.py +++ b/shiptrader/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from shiptrader.models import Starship +from shiptrader.models import Starship, Listing class StarshipSerializer(serializers.ModelSerializer): @@ -8,3 +8,11 @@ class StarshipSerializer(serializers.ModelSerializer): class Meta: model = Starship fields = '__all__' + + +class ListingSerializer(serializers.ModelSerializer): + + class Meta: + model = Listing + fields = '__all__' + read_only_fields = ('last_listed', 'active',) diff --git a/shiptrader/tests/test_views.py b/shiptrader/tests/test_views.py index a0b1ff1..b8bd821 100644 --- a/shiptrader/tests/test_views.py +++ b/shiptrader/tests/test_views.py @@ -1,31 +1,38 @@ +from datetime import datetime, timedelta + from django.urls import reverse +from django.utils.timezone import now from rest_framework import status from rest_framework.test import APITestCase -from shiptrader.models import Starship +from shiptrader.models import Starship, Listing + + +def create_starships(): + Starship( + starship_class='cruiser', + manufacturer='SpaceX', + length=12.5, + hyperdrive_rating=1.3, + cargo_capacity=1800, + crew=3, + passengers=4, + ).save() + Starship( + starship_class='battleship', + manufacturer='BlueOrigin', + length=5.5, + hyperdrive_rating=5.0, + cargo_capacity=500, + crew=1, + passengers=2, + ).save() class StarshipTestCase(APITestCase): def setUp(self): - Starship( - starship_class='cruiser', - manufacturer='SpaceX', - length=12.5, - hyperdrive_rating=1.3, - cargo_capacity=1800, - crew=3, - passengers=4, - ).save() - Starship( - starship_class='battleship', - manufacturer='BlueOrigin', - length=5.5, - hyperdrive_rating=5.0, - cargo_capacity=500, - crew=1, - passengers=2, - ).save() + create_starships() def test_get_starship_list(self): response = self.client.get( @@ -40,4 +47,84 @@ def test_get_starship_detail(self): reverse('starship-detail', kwargs={'pk': pk}), format='json' ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['starship_class'], 'cruiser') \ No newline at end of file + self.assertEqual(response.data['starship_class'], 'cruiser') + + +class ListingTestCase(APITestCase): + + def setUp(self): + create_starships() + + def test_create_listing(self): + starship = Starship.objects.first() + response = self.client.post( + reverse('listing-list'), format='json', + data={'name': 'Victory', 'price': 10, 'ship_type': starship.id} + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Listing.objects.all().count(), 1) + self.assertEqual(Listing.objects.first().ship_type, starship) + self.assertEqual(Listing.objects.first().active, True) + self.assertLess( + now() - Listing.objects.first().last_listed, + timedelta(seconds=1) + ) + + def test_list_listings_by_price(self): + starship_1 = Starship.objects.all()[0] + starship_2 = Starship.objects.all()[1] + Listing(name='Victory', price=10, ship_type=starship_1).save() + Listing(name='Arrow', price=50, ship_type=starship_2).save() + + response = self.client.get( + reverse('listing-list'), {'ordering': '-price'} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data['results'][0]['name'], + 'Arrow' + ) + self.assertEqual( + response.data['results'][1]['name'], + 'Victory' + ) + + def test_list_listings_by_date(self): + starship_1 = Starship.objects.all()[0] + starship_2 = Starship.objects.all()[1] + Listing(name='Victory', price=10, ship_type=starship_1).save() + Listing( + name='Arrow', price=50, ship_type=starship_2, + last_listed=now() - timedelta(days=3) + ).save() + + response = self.client.get( + reverse('listing-list'), {'ordering': 'last_listed'}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 2) + self.assertEqual( + response.data['results'][0]['name'], + 'Arrow' + ) + self.assertEqual( + response.data['results'][1]['name'], + 'Victory' + ) + + def test_filter_listings_by_class(self): + starship_1 = Starship.objects.all()[0] + starship_2 = Starship.objects.all()[1] + Listing(name='Victory', price=10, ship_type=starship_1).save() + Listing(name='Arrow', price=50, ship_type=starship_2).save() + + response = self.client.get( + reverse('listing-list'), + {'starship_class': starship_1.starship_class} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 1) + self.assertEqual( + response.data['results'][0]['name'], + 'Victory' + ) diff --git a/shiptrader/urls.py b/shiptrader/urls.py index c67e03a..232e2cd 100644 --- a/shiptrader/urls.py +++ b/shiptrader/urls.py @@ -4,9 +4,10 @@ from . import views -starship_router = routers.DefaultRouter() -starship_router.register(r'starships', views.StarshipView) +router = routers.DefaultRouter() +router.register(r'starships', views.StarshipView) +router.register(r'listings', views.ListingView) urlpatterns = [ - url(r'^', include(starship_router.urls)), + url(r'^', include(router.urls)), ] diff --git a/shiptrader/views.py b/shiptrader/views.py index c7b9050..87f3a3c 100644 --- a/shiptrader/views.py +++ b/shiptrader/views.py @@ -1,7 +1,10 @@ +import django_filters +from django_filters.rest_framework import DjangoFilterBackend from rest_framework import mixins, viewsets +from rest_framework.filters import OrderingFilter -from shiptrader.serializers import StarshipSerializer -from shiptrader.models import Starship +from shiptrader.serializers import StarshipSerializer, ListingSerializer +from shiptrader.models import Starship, Listing class StarshipView( @@ -9,3 +12,31 @@ class StarshipView( ): queryset = Starship.objects.all().order_by('pk') serializer_class = StarshipSerializer + + +class ListingFilter(django_filters.FilterSet): + starship_class = django_filters.CharFilter( + field_name='ship_type__starship_class' + ) + + class Meta: + model = Listing + fields = {} + +class SafeOrderingFilter(OrderingFilter): + + def get_ordering(self, request, queryset, view): + ordering = super().get_ordering(request, queryset, view) + if ordering and 'id' not in ordering: + return list(ordering) + ['id'] + return ordering + +class ListingView( + mixins.RetrieveModelMixin, mixins.ListModelMixin, + mixins.CreateModelMixin, viewsets.GenericViewSet +): + queryset = Listing.objects.filter(active=True).order_by('pk') + filter_backends = (DjangoFilterBackend, SafeOrderingFilter,) + filter_class = ListingFilter + serializer_class = ListingSerializer + ordering_fields = ('price', 'last_listed',) diff --git a/testsite/settings.py b/testsite/settings.py index 844f297..3f644ee 100644 --- a/testsite/settings.py +++ b/testsite/settings.py @@ -41,6 +41,7 @@ 'django.contrib.staticfiles', 'shiptrader', 'rest_framework', + 'django_filters', ] MIDDLEWARE = [ From 37811ca6ffe40ad0b95422101a8ba775cf1d74dd Mon Sep 17 00:00:00 2001 From: Ian Brechin Date: Wed, 15 May 2019 21:57:24 +0100 Subject: [PATCH 6/8] Allow clients to deactivate and reactivate listings --- shiptrader/tests/test_views.py | 31 ++++++++++++++++++++++- shiptrader/views.py | 46 ++++++++++++++++++++++++++++------ 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/shiptrader/tests/test_views.py b/shiptrader/tests/test_views.py index b8bd821..b7c6c1d 100644 --- a/shiptrader/tests/test_views.py +++ b/shiptrader/tests/test_views.py @@ -95,7 +95,7 @@ def test_list_listings_by_date(self): Listing(name='Victory', price=10, ship_type=starship_1).save() Listing( name='Arrow', price=50, ship_type=starship_2, - last_listed=now() - timedelta(days=3) + last_listed=now() - timedelta(days=3) ).save() response = self.client.get( @@ -128,3 +128,32 @@ def test_filter_listings_by_class(self): response.data['results'][0]['name'], 'Victory' ) + + def test_deactivate_listing(self): + starship_1 = Starship.objects.all()[0] + listing = Listing(name='Victory', price=10, ship_type=starship_1) + listing.save() + + response = self.client.post( + reverse('listing-deactivate', kwargs={'pk': listing.id}), + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(Listing.objects.get(pk=listing.id).active) + + def test_activate_listing(self): + starship_1 = Starship.objects.all()[0] + listing = Listing( + name='Victory', price=10, ship_type=starship_1, + active=False, last_listed=now() - timedelta(days=3) + ) + listing.save() + + response = self.client.post( + reverse('listing-activate', kwargs={'pk': listing.id}), + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertTrue(Listing.objects.get(pk=listing.id).active) + self.assertLess( + now() - Listing.objects.get(pk=listing.id).last_listed, + timedelta(seconds=1) + ) diff --git a/shiptrader/views.py b/shiptrader/views.py index 87f3a3c..0246904 100644 --- a/shiptrader/views.py +++ b/shiptrader/views.py @@ -1,7 +1,9 @@ import django_filters from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import mixins, viewsets +from django.utils.timezone import now +from rest_framework import mixins, viewsets, decorators, status from rest_framework.filters import OrderingFilter +from rest_framework.response import Response from shiptrader.serializers import StarshipSerializer, ListingSerializer from shiptrader.models import Starship, Listing @@ -21,22 +23,52 @@ class ListingFilter(django_filters.FilterSet): class Meta: model = Listing - fields = {} + fields = { + 'active': ['exact'] + } -class SafeOrderingFilter(OrderingFilter): +class StableOrderingFilter(OrderingFilter): + """ + This filter maintains a stable sort when ordering by a field + that may have duplicate values by adding a secondary ordering + by pk. This keeps pagination sane. + """ def get_ordering(self, request, queryset, view): ordering = super().get_ordering(request, queryset, view) - if ordering and 'id' not in ordering: - return list(ordering) + ['id'] + if ordering and 'pk' not in ordering: + return list(ordering) + ['pk'] return ordering + class ListingView( mixins.RetrieveModelMixin, mixins.ListModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet ): - queryset = Listing.objects.filter(active=True).order_by('pk') - filter_backends = (DjangoFilterBackend, SafeOrderingFilter,) + queryset = Listing.objects.order_by('pk') + filter_backends = (DjangoFilterBackend, StableOrderingFilter,) filter_class = ListingFilter serializer_class = ListingSerializer ordering_fields = ('price', 'last_listed',) + + @decorators.action( + methods=['post'], detail=True, + url_path='activate', url_name='activate' + ) + def activate(self, request, pk=None): + obj = self.get_object() + if not obj.active: + obj.active = True + obj.last_listed = now() + obj.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + @decorators.action( + methods=['post'], detail=True, + url_path='deactivate', url_name='deactivate' + ) + def deactivate(self, request, pk=None): + obj = self.get_object() + obj.active = False + obj.save() + return Response(status=status.HTTP_204_NO_CONTENT) From f3a492ae839d78ee5394de8c505738e00c5217d4 Mon Sep 17 00:00:00 2001 From: Ian Brechin Date: Thu, 16 May 2019 13:31:38 +0100 Subject: [PATCH 7/8] Change default in migration to avoid naive datetime warnings --- shiptrader/migrations/0003_auto_20190515_2029.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shiptrader/migrations/0003_auto_20190515_2029.py b/shiptrader/migrations/0003_auto_20190515_2029.py index 1a94540..6702cab 100644 --- a/shiptrader/migrations/0003_auto_20190515_2029.py +++ b/shiptrader/migrations/0003_auto_20190515_2029.py @@ -2,8 +2,8 @@ # Generated by Django 1.11.20 on 2019-05-15 20:29 from __future__ import unicode_literals -import datetime from django.db import migrations, models +from django.utils import timezone class Migration(migrations.Migration): @@ -21,6 +21,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='listing', name='last_listed', - field=models.DateTimeField(default=datetime.datetime.now), + field=models.DateTimeField(default=timezone.now), ), ] From 72ef9f5b8903b9e8782bf73fd9d6fadff20b20f9 Mon Sep 17 00:00:00 2001 From: Ian Brechin Date: Thu, 16 May 2019 13:31:58 +0100 Subject: [PATCH 8/8] Add README for API usage and cleanup --- README.md | 106 +++++++++++++++------------------ shiptrader/admin.py | 7 ++- shiptrader/models.py | 6 ++ shiptrader/tests/test_views.py | 53 ++++++++++------- shiptrader/views.py | 2 +- testsite/settings.py | 12 ---- 6 files changed, 92 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index a83e86e..308ed93 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,47 @@ -# Ostmodern Python Code Test - -The goal of this exercise is to test that you know your way around Django and -REST APIs. Approach it the way you would an actual long-term project. - -The idea is to build a platform on which your users can buy and sell Starships. -To make this process more transparent, it has been decided to source some -technical information about the Starships on sale from the [Starship -API](https://swapi.co/documentation#starships). - -A Django project some initial data models have been created already. You may need -to do some additional data modelling to satify the requirements. - -## Getting started - -* This test works with either - [Docker](https://docs.docker.com/compose/install/#install-compose) or - [Vagrant](https://www.vagrantup.com/downloads.html) -* Get the code from `https://github.com/ostmodern/python-code-test` -* Do all your work in your own `develop` branch -* Once you have downloaded the code the following commands will get the site up - and running - -```shell -# For Docker -docker-compose up -# You can run `manage.py` commands using the `./manapy` wrapper - -# For Vagrant -vagrant up -vagrant ssh -# Inside the box -./manage.py runserver 0.0.0.0:8008 -``` -* The default Django "It worked!" page should now be available at - http://localhost:8008/ - -## Tasks - -Your task is to build a JSON-based REST API for your frontend developers to -consume. You have built a list of user stories with your colleagues, but you get -to decide how to design the API. Remember that the frontend developers will need -some documentation of your API to understand how to use it. - -We do not need you to implement users or authentication, to reduce the amount of -time this exercise will take to complete. You may use any external libraries you -require. - -* We need to be able to import all existing - [Starships](https://swapi.co/documentation#starships) to the provided Starship - Model -* A potential buyer can browse all Starships -* A potential buyer can browse all the listings for a given `starship_class` -* A potential buyer can sort listings by price or time of listing -* To list a Starship as for sale, the user should supply the Starship name and - list price -* A seller can deactivate and reactivate their listing - -After you are done, create a release branch in your repo and send us the link. +# Starship Listing API + +This API allows for the creation of starship sale listings. The root of the API is at `/api/` - if you request this path it will return the available endpoints. You can also explore the API using a web browser. + +# Endpoints + +## Starships + +The starship endpoint `/api/starships/` lists the available ship types and their IDs. You will need the ID of the relevant ship type when creating a listing. + +## Listings + +The listings API allows for the creation and retrieval of sale listings. To create a listing, make a POST to `/api/listings/` with the fields `name`, `price` and `ship_type`. `ship_type` should be the ID of an existing starship. + +Listings can be retrieved by a GET request to `/api/listings/`. You can use query parameters to order and filter the returned list. You can filter by `starship_class` and `active` status. + +e.g. to filter by `starship_class`: + +``` +/api/listings/?starship_class=cruiser +``` + +You can also order by `price` or `last_listed` using the `ordering` parameter. + +e.g. to order by `price`: + +``` +/api/listings/?ordering=price +``` + +### Activating and deactivating listings + +You can activate and deactivate a listing by POSTing to the relevant action endpoints. + +e.g. to deactivate listing with ID 1, POST to: + +``` +/api/listings/1/deactivate/ +``` + +and to reactivate: + +``` +/api/listings/1/activate/ +``` + +The `last_listed` timestamp is updated when a listing is reactivated. It is recommended that when retrieving listings for display, you should filter them with `active=true`. diff --git a/shiptrader/admin.py b/shiptrader/admin.py index 2062118..7447c8b 100644 --- a/shiptrader/admin.py +++ b/shiptrader/admin.py @@ -1,8 +1,13 @@ from django.contrib import admin -from shiptrader.models import Starship +from shiptrader.models import Starship, Listing @admin.register(Starship) class StarshipAdmin(admin.ModelAdmin): list_display = ('starship_class', 'manufacturer') + + +@admin.register(Listing) +class ListingAdmin(admin.ModelAdmin): + list_display = ('name', 'price') diff --git a/shiptrader/models.py b/shiptrader/models.py index 69539cc..3f81086 100644 --- a/shiptrader/models.py +++ b/shiptrader/models.py @@ -13,6 +13,9 @@ class Starship(models.Model): crew = models.IntegerField(blank=True, null=True) passengers = models.IntegerField(blank=True, null=True) + def __str__(self): + return '{}: {}, {}'.format(self.pk, self.manufacturer, self.starship_class) + class Listing(models.Model): name = models.CharField(max_length=255) @@ -20,3 +23,6 @@ class Listing(models.Model): price = models.IntegerField() active = models.BooleanField(default=True) last_listed = models.DateTimeField(default=now) + + def __str__(self): + return '{}: {}, {}'.format(self.pk, self.name, self.price) diff --git a/shiptrader/tests/test_views.py b/shiptrader/tests/test_views.py index b7c6c1d..46374b2 100644 --- a/shiptrader/tests/test_views.py +++ b/shiptrader/tests/test_views.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import timedelta from django.urls import reverse from django.utils.timezone import now @@ -54,16 +54,17 @@ class ListingTestCase(APITestCase): def setUp(self): create_starships() + self.starship_1 = Starship.objects.all()[0] + self.starship_2 = Starship.objects.all()[1] def test_create_listing(self): - starship = Starship.objects.first() response = self.client.post( reverse('listing-list'), format='json', - data={'name': 'Victory', 'price': 10, 'ship_type': starship.id} + data={'name': 'Victory', 'price': 10, 'ship_type': self.starship_1.id} ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(Listing.objects.all().count(), 1) - self.assertEqual(Listing.objects.first().ship_type, starship) + self.assertEqual(Listing.objects.first().ship_type, self.starship_1) self.assertEqual(Listing.objects.first().active, True) self.assertLess( now() - Listing.objects.first().last_listed, @@ -71,10 +72,8 @@ def test_create_listing(self): ) def test_list_listings_by_price(self): - starship_1 = Starship.objects.all()[0] - starship_2 = Starship.objects.all()[1] - Listing(name='Victory', price=10, ship_type=starship_1).save() - Listing(name='Arrow', price=50, ship_type=starship_2).save() + Listing(name='Victory', price=10, ship_type=self.starship_1).save() + Listing(name='Arrow', price=50, ship_type=self.starship_2).save() response = self.client.get( reverse('listing-list'), {'ordering': '-price'} @@ -90,11 +89,9 @@ def test_list_listings_by_price(self): ) def test_list_listings_by_date(self): - starship_1 = Starship.objects.all()[0] - starship_2 = Starship.objects.all()[1] - Listing(name='Victory', price=10, ship_type=starship_1).save() + Listing(name='Victory', price=10, ship_type=self.starship_1).save() Listing( - name='Arrow', price=50, ship_type=starship_2, + name='Arrow', price=50, ship_type=self.starship_2, last_listed=now() - timedelta(days=3) ).save() @@ -113,14 +110,12 @@ def test_list_listings_by_date(self): ) def test_filter_listings_by_class(self): - starship_1 = Starship.objects.all()[0] - starship_2 = Starship.objects.all()[1] - Listing(name='Victory', price=10, ship_type=starship_1).save() - Listing(name='Arrow', price=50, ship_type=starship_2).save() + Listing(name='Victory', price=10, ship_type=self.starship_1).save() + Listing(name='Arrow', price=50, ship_type=self.starship_2).save() response = self.client.get( reverse('listing-list'), - {'starship_class': starship_1.starship_class} + {'starship_class': self.starship_1.starship_class} ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['count'], 1) @@ -130,8 +125,7 @@ def test_filter_listings_by_class(self): ) def test_deactivate_listing(self): - starship_1 = Starship.objects.all()[0] - listing = Listing(name='Victory', price=10, ship_type=starship_1) + listing = Listing(name='Victory', price=10, ship_type=self.starship_1) listing.save() response = self.client.post( @@ -141,9 +135,8 @@ def test_deactivate_listing(self): self.assertFalse(Listing.objects.get(pk=listing.id).active) def test_activate_listing(self): - starship_1 = Starship.objects.all()[0] listing = Listing( - name='Victory', price=10, ship_type=starship_1, + name='Victory', price=10, ship_type=self.starship_1, active=False, last_listed=now() - timedelta(days=3) ) listing.save() @@ -157,3 +150,21 @@ def test_activate_listing(self): now() - Listing.objects.get(pk=listing.id).last_listed, timedelta(seconds=1) ) + + def test_activate_active_listing_does_not_update_last_listed(self): + first_listed = now() - timedelta(days=3) + listing = Listing( + name='Victory', price=10, ship_type=self.starship_1, + last_listed=first_listed + ) + listing.save() + + response = self.client.post( + reverse('listing-activate', kwargs={'pk': listing.id}), + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertTrue(Listing.objects.get(pk=listing.id).active) + self.assertEqual( + Listing.objects.get(pk=listing.id).last_listed, + first_listed + ) diff --git a/shiptrader/views.py b/shiptrader/views.py index 0246904..044ebe5 100644 --- a/shiptrader/views.py +++ b/shiptrader/views.py @@ -19,7 +19,7 @@ class StarshipView( class ListingFilter(django_filters.FilterSet): starship_class = django_filters.CharFilter( field_name='ship_type__starship_class' - ) + ) class Meta: model = Listing diff --git a/testsite/settings.py b/testsite/settings.py index 3f644ee..aa1586b 100644 --- a/testsite/settings.py +++ b/testsite/settings.py @@ -136,15 +136,3 @@ 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 'PAGE_SIZE': 20 } - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': os.environ.get('DB_NAME', 'shiptrader'), - 'USER': os.environ.get('DB_USERNAME', 'postgres'), - 'PASSWORD': os.environ.get('DB_PASSWORD', 'postgres'), - 'HOST': os.environ.get('DB_HOST', 'localhost'), - 'PORT': os.environ.get('DB_PORT', ''), - } -} -