From 5a44e7fb4ea9a8d699b6937f549e30dff5fe0f7f Mon Sep 17 00:00:00 2001 From: Les Orchard Date: Mon, 24 Aug 2015 19:57:55 -0400 Subject: [PATCH] Issue #24: First steps toward REST API * Models for Experiment, ExperimentDetail, and UserInstallation * Admin views for the models * Added Django REST Framework as dependency * Added serializers and view sets for REST API * Tweak to ./bin/run-dev.sh to restart the dev server after a second when it crashes --- .gitignore | 1 + bin/run-dev.sh | 8 +- idea_town/experiments/__init__.py | 0 idea_town/experiments/admin.py | 38 ++++++ .../experiments/migrations/0001_initial.py | 60 ++++++++++ idea_town/experiments/migrations/__init__.py | 0 idea_town/experiments/models.py | 52 ++++++++ idea_town/experiments/serializers.py | 40 +++++++ idea_town/experiments/tests.py | 29 +++++ idea_town/experiments/urls.py | 0 idea_town/experiments/views.py | 45 +++++++ idea_town/settings.py | 17 ++- idea_town/urls.py | 10 ++ idea_town/utils.py | 113 ++++++++++++++++++ requirements.txt | 15 +++ 15 files changed, 426 insertions(+), 2 deletions(-) create mode 100644 idea_town/experiments/__init__.py create mode 100644 idea_town/experiments/admin.py create mode 100644 idea_town/experiments/migrations/0001_initial.py create mode 100644 idea_town/experiments/migrations/__init__.py create mode 100644 idea_town/experiments/models.py create mode 100644 idea_town/experiments/serializers.py create mode 100644 idea_town/experiments/tests.py create mode 100644 idea_town/experiments/urls.py create mode 100644 idea_town/experiments/views.py create mode 100644 idea_town/utils.py diff --git a/.gitignore b/.gitignore index aaefcd2846..0e05b0dd85 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ docs/_build MANIFEST .coverage node_modules/ +media/ static/ client-build/ client-src/vendor/ diff --git a/bin/run-dev.sh b/bin/run-dev.sh index 6fe5822cbc..b8b37eb7ac 100755 --- a/bin/run-dev.sh +++ b/bin/run-dev.sh @@ -1,4 +1,10 @@ #!/bin/sh ./bin/run-common.sh ./manage.py loaddata fixtures/initial_data_dev.json -./manage.py runserver 0.0.0.0:8000 + +# Try running this in a loop, so that the whole container doesn't exit when +# runserver reloads and hits an error +while [ 1 ]; do + ./manage.py runserver 0.0.0.0:8000 + sleep 1 +done diff --git a/idea_town/experiments/__init__.py b/idea_town/experiments/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/idea_town/experiments/admin.py b/idea_town/experiments/admin.py new file mode 100644 index 0000000000..d02ee1f41e --- /dev/null +++ b/idea_town/experiments/admin.py @@ -0,0 +1,38 @@ +from django.contrib import admin + +from .models import (Experiment, ExperimentDetail, UserInstallation) +from ..utils import (show_image, parent_link, related_changelist_link) + + +class ExperimentDetailInline(admin.TabularInline): + model = ExperimentDetail + + +class ExperimentAdmin(admin.ModelAdmin): + + list_display = ('id', 'title', show_image('thumbnail'), + related_changelist_link('details'), + related_changelist_link('users'), + 'created', 'modified',) + + prepopulated_fields = {"slug": ("title",)} + + inlines = (ExperimentDetailInline,) + + +class ExperimentDetailAdmin(admin.ModelAdmin): + + list_display = ('id', parent_link('experiment'), 'order', 'headline', + show_image('image'), 'created', 'modified',) + + +class UserInstallationAdmin(admin.ModelAdmin): + + list_display = ('id', parent_link('experiment'), parent_link('user'), + 'created', 'modified',) + + +for x in ((Experiment, ExperimentAdmin), + (ExperimentDetail, ExperimentDetailAdmin), + (UserInstallation, UserInstallationAdmin),): + admin.site.register(*x) diff --git a/idea_town/experiments/migrations/0001_initial.py b/idea_town/experiments/migrations/0001_initial.py new file mode 100644 index 0000000000..1f96ef085c --- /dev/null +++ b/idea_town/experiments/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings +import idea_town.utils + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Experiment', + fields=[ + ('id', models.AutoField(auto_created=True, verbose_name='ID', serialize=False, primary_key=True)), + ('title', models.CharField(max_length=128)), + ('slug', models.SlugField(unique=True, max_length=128)), + ('thumbnail', models.ImageField(upload_to=idea_town.utils.HashedUploadTo('thumbnail'))), + ('description', models.TextField()), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='ExperimentDetail', + fields=[ + ('id', models.AutoField(auto_created=True, verbose_name='ID', serialize=False, primary_key=True)), + ('order', models.IntegerField(default=0)), + ('headline', models.CharField(max_length=256)), + ('image', models.ImageField(upload_to=idea_town.utils.HashedUploadTo('image'))), + ('copy', models.TextField()), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('experiment', models.ForeignKey(related_name='details', to='experiments.Experiment')), + ], + options={ + 'ordering': ('experiment', 'order', 'modified'), + }, + ), + migrations.CreateModel( + name='UserInstallation', + fields=[ + ('id', models.AutoField(auto_created=True, verbose_name='ID', serialize=False, primary_key=True)), + ('rating', models.FloatField(null=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('experiment', models.ForeignKey(to='experiments.Experiment')), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='experiment', + name='users', + field=models.ManyToManyField(to=settings.AUTH_USER_MODEL, through='experiments.UserInstallation'), + ), + ] diff --git a/idea_town/experiments/migrations/__init__.py b/idea_town/experiments/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/idea_town/experiments/models.py b/idea_town/experiments/models.py new file mode 100644 index 0000000000..5abd4bec7e --- /dev/null +++ b/idea_town/experiments/models.py @@ -0,0 +1,52 @@ +from django.db import models +from django.contrib.auth.models import User + +from ..utils import HashedUploadTo + + +experiment_thumbnail_upload_to = HashedUploadTo('thumbnail') +experimentdetail_image_upload_to = HashedUploadTo('image') + + +class Experiment(models.Model): + + title = models.CharField(max_length=128) + slug = models.SlugField(max_length=128, unique=True, db_index=True) + thumbnail = models.ImageField(upload_to=experiment_thumbnail_upload_to) + description = models.TextField() + + users = models.ManyToManyField(User, through='UserInstallation') + + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.title + + +class ExperimentDetail(models.Model): + + experiment = models.ForeignKey('Experiment', related_name='details', + db_index=True) + + order = models.IntegerField(default=0) + headline = models.CharField(max_length=256) + image = models.ImageField(upload_to=experimentdetail_image_upload_to) + copy = models.TextField() + + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ('experiment', 'order', 'modified',) + + +class UserInstallation(models.Model): + + experiment = models.ForeignKey(Experiment) + user = models.ForeignKey(User) + + rating = models.FloatField(null=True, blank=True) + + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) diff --git a/idea_town/experiments/serializers.py b/idea_town/experiments/serializers.py new file mode 100644 index 0000000000..61b2b911b7 --- /dev/null +++ b/idea_town/experiments/serializers.py @@ -0,0 +1,40 @@ +from rest_framework import serializers +from django.core.urlresolvers import reverse + +from .models import (Experiment, ExperimentDetail) + + +class ExperimentDetailSerializer(serializers.HyperlinkedModelSerializer): + experiment_url = serializers.SerializerMethodField() + + class Meta: + model = ExperimentDetail + fields = ('url', 'order', 'headline', 'image', 'copy', 'experiment_url') + + def get_experiment_url(self, obj): + request = self.context['request'] + path = reverse('experiment-detail', args=(obj.experiment.pk,)) + return request.build_absolute_uri(path) + + +class ExperimentDeepSerializer(serializers.HyperlinkedModelSerializer): + """Deep Experiment serializer that includes ExperimentDetails""" + details = ExperimentDetailSerializer(many=True, read_only=True) + + class Meta: + model = Experiment + fields = ('url', 'title', 'slug', 'thumbnail', 'description', 'details') + + +class ExperimentSerializer(serializers.HyperlinkedModelSerializer): + """Shallow Experiment serializer that links to a set of ExperimentDetails""" + details_url = serializers.SerializerMethodField() + + class Meta: + model = Experiment + fields = ('url', 'title', 'slug', 'thumbnail', 'description', 'details_url') + + def get_details_url(self, obj): + request = self.context['request'] + path = reverse('experiment-details', args=(obj.pk,)) + return request.build_absolute_uri(path) diff --git a/idea_town/experiments/tests.py b/idea_town/experiments/tests.py new file mode 100644 index 0000000000..30aa236deb --- /dev/null +++ b/idea_town/experiments/tests.py @@ -0,0 +1,29 @@ +from django.core.urlresolvers import reverse +from django.test import TestCase +from django.test import Client + +from .models import (Experiment) # , ExperimentDetail, UserInstallation) + +import logging +logger = logging.getLogger(__name__) + + +class ExperimentViewTests(TestCase): + + @classmethod + def setUpTestData(cls): + + cls.experiments = dict((obj.slug, obj) for obj in ( + Experiment.objects.create(**kwargs) for kwargs in ( + dict(title="Test 1", slug="test-1", description="This is a test"), + dict(title="Test 2", slug="test-2", description="This is a test"), + dict(title="Test 3", slug="test-3", description="This is a test"), + ))) + + def setUp(self): + self.client = Client() + + def test_index(self): + url = reverse('experiment-list') + resp = self.client.get(url) + logger.debug(resp.content) diff --git a/idea_town/experiments/urls.py b/idea_town/experiments/urls.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/idea_town/experiments/views.py b/idea_town/experiments/views.py new file mode 100644 index 0000000000..b0d312d687 --- /dev/null +++ b/idea_town/experiments/views.py @@ -0,0 +1,45 @@ +from rest_framework import viewsets +from rest_framework.decorators import detail_route +from rest_framework.response import Response +from .models import (Experiment, ExperimentDetail) +from .serializers import (ExperimentSerializer, ExperimentDeepSerializer, + ExperimentDetailSerializer) + +import logging +logger = logging.getLogger(__name__) + + +class ExperimentViewSet(viewsets.ModelViewSet): + """ + Returns a list of all Experiments in the system. + """ + queryset = Experiment.objects.all() + serializer_class = ExperimentSerializer + + def retrieve(self, request, *args, **kwargs): + """Use the deep serializer for individual retrieval, which includes + ExperimentDetail items""" + instance = self.get_object() + serializer = ExperimentDeepSerializer( + instance, context=self.get_serializer_context()) + return Response(serializer.data) + + @detail_route() + def details(self, request, pk=None): + instance = self.get_object() + queryset = instance.details.all() + serializer = ExperimentDetailSerializer( + queryset, many=True, + context=self.get_serializer_context()) + return Response(serializer.data) + + +class ExperimentDetailViewSet(viewsets.ModelViewSet): + queryset = ExperimentDetail.objects.all() + serializer_class = ExperimentDetailSerializer + + +def register_views(router): + router.register(r'experiments', ExperimentViewSet) + router.register(r'details', ExperimentDetailViewSet) + logger.debug(router.get_urls()) diff --git a/idea_town/settings.py b/idea_town/settings.py index 3ca7e5b5cb..d59eea781c 100644 --- a/idea_town/settings.py +++ b/idea_town/settings.py @@ -36,10 +36,15 @@ # Project specific apps 'idea_town.base', 'idea_town.accounts', + 'idea_town.experiments', # Third party apps 'django_jinja', + 'django_cleanup', + 'rest_framework', + + # FxA auth handling 'allauth', 'allauth.account', 'allauth.socialaccount', @@ -79,6 +84,15 @@ 'allauth.account.auth_backends.AuthenticationBackend', ) +REST_FRAMEWORK = { + # Use Django's standard `django.contrib.auth` permissions, + # or allow read-only access for unauthenticated users. + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly' + ], + 'PAGE_SIZE': 10 +} + SOCIALACCOUNT_PROVIDERS = { 'fxa': dict( ACCESS_TOKEN_URL=config( @@ -145,12 +159,13 @@ 'BACKEND': 'django_jinja.backend.Jinja2', 'APP_DIRS': True, 'OPTIONS': { - 'match_regex': r'^(?!(admin)/.*)', + 'match_regex': r'^(?!(admin|rest_framework)/.*)', 'match_extension': '.html', 'newstyle_gettext': True, 'context_processors': [ 'idea_town.base.context_processors.settings', 'idea_town.base.context_processors.i18n', + 'django.template.context_processors.media', ], } }, diff --git a/idea_town/urls.py b/idea_town/urls.py index 224e42a6e4..42699732a5 100644 --- a/idea_town/urls.py +++ b/idea_town/urls.py @@ -1,9 +1,19 @@ from django.conf.urls import patterns, include, url from django.contrib import admin +from rest_framework import routers + +from .experiments import views as experiment_views + + +router = routers.DefaultRouter() +experiment_views.register_views(router) + urlpatterns = patterns( '', url(r'^$', 'idea_town.base.views.home', name='home'), url(r'^accounts/', include('idea_town.accounts.urls')), url(r'^admin/', include(admin.site.urls)), + url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), + url(r'^api/', include(router.urls)), ) diff --git a/idea_town/utils.py b/idea_town/utils.py new file mode 100644 index 0000000000..febbe6e612 --- /dev/null +++ b/idea_town/utils.py @@ -0,0 +1,113 @@ +import hashlib +import random +import os +from time import time + +from django.conf import settings +from django.utils.deconstruct import deconstructible +from django.core.urlresolvers import reverse + +import logging +logger = logging.getLogger(__name__) + + +MK_UPLOAD_TMPL = getattr( + settings, 'MK_UPLOAD_TMPL', + '%(base)s/%(h1)s/%(h2)s/%(hash)s_%(field_fn)s_%(now)s_%(rand)04d%(ext)s') + + +@deconstructible +class HashedUploadTo(object): + """Builds an upload_to function for naming file uploads""" + + def __init__(self, field_fn, tmpl=MK_UPLOAD_TMPL): + self.field_fn = field_fn + self.tmpl = tmpl + + def __call__(self, instance, filename): + name, ext = os.path.splitext(filename) + base = instance._meta.db_table + slug = getattr(instance, 'slug', name) + hash = (hashlib.md5(slug.encode('utf-8', 'ignore')) + .hexdigest()) + return self.tmpl % dict( + now=int(time()), rand=random.randint(0, 1000), slug=slug[:50], + base=base, field_fn=self.field_fn, pk=instance.pk, hash=hash, + h1=hash[0], h2=hash[1], name=name, ext=ext) + + +def show_image(field_name, height="50"): + """Helper to show images in admin changelist views""" + + def show_image_display(obj): + if not hasattr(obj, field_name): + return 'None' + img_url = "%s%s" % (settings.MEDIA_URL, getattr(obj, field_name)) + return ('' % + (img_url, img_url, height)) + + show_image_display.allow_tags = True + show_image_display.short_description = field_name + + return show_image_display + + +def parent_link(field_name): + """Helper to link to parent model in admin changelists""" + + def build_link(self): + + parent = getattr(self, field_name) + + field = self._meta.get_field(field_name) + parent_app_name = field.related_model._meta.app_label + parent_model_name = field.related_model._meta.model_name + + url = reverse( + 'admin:%s_%s_change' % (parent_app_name, parent_model_name), + args=[parent.id]) + return '%s' % (url, parent) + + build_link.allow_tags = True + build_link.short_description = field_name + + return build_link + + +def related_changelist_link(field_name): + """Helper to link to related models from parent in admin changelists""" + + def build_link(self): + + field = self._meta.get_field(field_name) + queryset = getattr(self, field_name) + + parent_model_name = self._meta.model_name + + if hasattr(queryset, 'through'): + model = queryset.through + else: + model = field.related_model + + model_name = model._meta.model_name + app_name = model._meta.app_label + name_single = model._meta.verbose_name + name_plural = model._meta.verbose_name_plural + + link = '%s?%s' % ( + reverse('admin:%s_%s_changelist' % (app_name, model_name), args=[]), + '%s__exact=%s' % (parent_model_name, self.id)) + + new_link = '%s?%s' % ( + reverse('admin:%s_%s_add' % (app_name, model_name), args=[]), + '%s=%s' % (parent_model_name, self.id)) + + count = queryset.count() + what = (count == 1) and name_single or name_plural + return ('%s %s (add)' % + (link, count, what, new_link)) + + build_link.allow_tags = True + build_link.short_description = field_name + + return build_link diff --git a/requirements.txt b/requirements.txt index 341a21da0c..d0f2585a03 100644 --- a/requirements.txt +++ b/requirements.txt @@ -128,3 +128,18 @@ requests==2.7.0 # sha256: x9LubC6MWKD9YMLX0hzpPI0rIuep8jLUv5FTJq-0ea8 requests_oauthlib==0.5.0 + +# sha256: DxedfnXnyDtjQblZXKHzlN5wgUhKnjUq1m1VOhw9qik +Pillow==2.9.0 + +# sha256: R7yJ6jHs9gbi9FPg5MXWNAgs9dsWC48vszi1HCXmeRo +django-cleanup==0.3.1 + +# sha256: 3zu65Xjyl2dXG1zmekcC_hTtZxC5e6tZJRpRE7a-w6M +markdown==2.6.2 + +# sha256: AMxHk1r7vYMmD90oOwqnkOZY0qcZIgSfbkZ9yooSRTc +django-filter==0.11.0 + +# sha256: CYQuyxx8OOWmZsBLz6kk9oLvHoLZyECA8Zwot3HQgMU +djangorestframework==3.2.3