From 7bba5770e882c8d2ccae891956330c72e2f4832a Mon Sep 17 00:00:00 2001 From: Nikolai Kristiansen Date: Sun, 24 Nov 2013 01:52:17 +0100 Subject: [PATCH] Moves create member to separate resource. * Adds CORS support Tests are failing --- README.md | 8 +++- dusken/api/member.py | 99 +++++++++++++++++++++++++++++---------- dusken/authentication.py | 15 +++++- dusken/settings-sample.py | 15 +++++- dusken/urls.py | 3 +- dusken/utils/api.py | 19 ++++++++ dusken/views.py | 4 +- requirements.txt | 9 ++-- 8 files changed, 136 insertions(+), 36 deletions(-) create mode 100644 dusken/utils/api.py diff --git a/README.md b/README.md index 194d1b8..04dae83 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ You can find the documentation at [dusken.readthedocs.org](http://dusken.readthe pip install -r requirements.txt cd dusken cp settings-sample.py settings.py - # Edit database settings (sqlite3 is easiest to setup, but postgresql is recommended) + # Edit database settings (sqlite3 is easiest to setup, but postgresql is recommended. See discussion below.) cd .. ./manage.py syncdb ./manage.py runserver @@ -32,7 +32,6 @@ Download latest version of Vagrant from [downloads.vagrantup.com](http://downloa vagrant up vagrant ssh - ## Generate Fixtures for Test python manage.py dumpdata dusken. -n --indent=4 > dusken/fixtures/.json @@ -50,3 +49,8 @@ A similar data model might allready [exist](http://www.databaseanswers.org/data_ * [Tastypie](https://django-tastypie.readthedocs.org/) +## SQLite3 vs PostgreSQL +Tests run much faster with SQLite, but it does not check foreign key constraints out of the box [[1]](http://stackoverflow.com/questions/6745763/enable-integrity-checking-with-sqlite-in-django). + +SQLite is much easier to set up, but does not provide environment parity between development and production [[2]](http://12factor.net/dev-prod-parity). + diff --git a/dusken/api/member.py b/dusken/api/member.py index 94e6ac7..1c47634 100644 --- a/dusken/api/member.py +++ b/dusken/api/member.py @@ -1,15 +1,20 @@ +from django.conf.urls import url from django.contrib.auth.hashers import make_password from django.contrib.auth.models import User -from django.conf.urls import url +from django.db import IntegrityError from django.shortcuts import get_object_or_404 -from tastypie import fields -from tastypie.exceptions import ImmediateHttpResponse + +from tastypie.authorization import Authorization +from tastypie.exceptions import ImmediateHttpResponse, BadRequest from tastypie.http import HttpForbidden, HttpNoContent, HttpResponse, HttpAccepted +from tastypie import fields from tastypie.resources import ModelResource, ALL -from dusken.models import * -from dusken.authorization import MyDjangoAuthorization -from dusken.authentication import MyApiKeyAuthentication + from dusken.api.groupsbymember import GroupsByMemberResource +from dusken.authentication import MyApiKeyAuthentication, ServiceAuthentication +from dusken.authorization import MyDjangoAuthorization +from dusken.models import * +from dusken.utils.api import generate_username, random_string class MemberResource(ModelResource): @@ -21,10 +26,10 @@ class MemberResource(ModelResource): class Meta: queryset = Member.objects.all() resource_name = 'member' - list_allowed_methods = [ 'get', 'post' ] + list_allowed_methods = [ 'get' ] detail_allowed_methods = [ 'get', 'patch', 'delete' ] - authentication = MyApiKeyAuthentication() - authorization = MyDjangoAuthorization() + authentication = MyApiKeyAuthentication() # Who are you? + authorization = MyDjangoAuthorization() # What are you allowed to do? excludes = [ 'date_joined', 'password', 'is_active', 'is_staff', 'is_superuser', 'last_login' ] filtering = { 'first_name' : [ 'exact' ], @@ -70,25 +75,13 @@ class MemberResource(ModelResource): def hydrate(self, bundle): """ - Catches POST and PATCH requests and intercepts data. + Catches PATCH requests and intercepts data. """ - update_existing_user = bundle.obj.id is not None - if update_existing_user: - # If the user already exists, return an error when attempting to change the username. - if bundle.data['username'] != bundle.obj.username: - raise ImmediateHttpResponse(HttpForbidden("You can't change your username.")) - else: - # If creating new user, check if it already exists: - member_count = len(Member.objects.filter(username=bundle.data['username'])) \ - + len(Member.objects.filter(email=bundle.data['email'])) \ - + len(Member.objects.filter(phone_number=bundle.data['phone_number'])) - if member_count > 0: - # TODO Specify what data is missing - raise ImmediateHttpResponse(HttpForbidden("User with given data already exists")) + # If the user already exists, return an error when attempting to change the username. + if bundle.data['username'] != bundle.obj.username: + raise ImmediateHttpResponse(HttpForbidden("You can't change your username.")) - # Quick hack to allow new users to be created anonymously (unauthenticated) - bundle._anonymous_request_allowed = True return bundle def obj_update(self, bundle, **kwargs): @@ -114,6 +107,7 @@ class MemberResource(ModelResource): try: country = Country.objects.get(name=new_address['country']) except Country.DoesNotExist, e: + # FIXME change to badrequest? raise ImmediateHttpResponse(HttpForbidden( 'The country "{}" does not exist.'.format(new_address['country'])) ) @@ -165,3 +159,58 @@ class MemberResource(ModelResource): member.is_active = False member.save() return HttpNoContent() + +class MemberCreateResource(ModelResource): + # TODO + # - get the post request to return auth info + # - do not return fields in excludes list + """ + This class provides the following endpoint: + (1) /api/v1/register/ + + The create Member resource is needed because: + - You need a user account to authenticate to our API + - and you need to to be authenticated to create a user account. + + Ref: http://psjinx.com/programming/2013/06/07/so-you-want-to-create-users-using-djangotastypie/ + """ + class Meta: + object_class = Member + resource_name = "register" + allowed_methods = ['post'] + default_format = "application/json" + always_return_data = True + queryset = Member.objects.all() + authentication = ServiceAuthentication() # anyone + authorization = Authorization() # can do what they want with anything + excludes = [ 'date_joined', 'password', 'is_active', 'is_staff', 'is_superuser', 'last_login' ] + + def obj_create(self, bundle, request=None, **kwargs): + # TODO factor out validation http://django-tastypie.readthedocs.org/en/latest/validation.html + # email must be unique + email = bundle.data.get('email') + if(len(Member.objects.filter(email=email)) > 0): + raise BadRequest("E-mail '{0}' already exists".format(email)) + + password = bundle.data.get('password') + if not password: + # generate a random password if it is not set + bundle.data['password'] = random_string(32); + + username = bundle.data.get('username') + if not username: + username = bundle.data['username'] = generate_username(bundle.data) + # FIXME unsafe + while(len(Member.objects.filter(username=username)) > 0): + username = bundle.data['username'] = generate_username(bundle.data) + + # When creating a new user, check if it already exists: + try: + bundle = super(MemberCreateResource, self).obj_create(bundle, **kwargs) + bundle.obj.set_password(password) + bundle.obj.save() + except IntegrityError as e: + print e + raise BadRequest('That username already exists') + + return bundle diff --git a/dusken/authentication.py b/dusken/authentication.py index 7d18d6b..92d8aff 100644 --- a/dusken/authentication.py +++ b/dusken/authentication.py @@ -1,6 +1,8 @@ -from tastypie.authentication import ApiKeyAuthentication +from tastypie.authentication import ApiKeyAuthentication, Authentication from tastypie.http import HttpUnauthorized +# TODO replace with Oauth2 http://django-tastypie.readthedocs.org/en/latest/cookbook.html#creating-a-full-oauth-2-0-api + class MyApiKeyAuthentication(ApiKeyAuthentication): """ Override method below to use our custom User model @@ -40,3 +42,14 @@ class MyApiKeyAuthentication(ApiKeyAuthentication): request.user = user return key_auth_check + + +class ServiceAuthentication(Authentication): + pass +# + # TODO auth for services + # + #def is_authenticated(self, request, **kwargs): + # return True + # return False + diff --git a/dusken/settings-sample.py b/dusken/settings-sample.py index 45e821a..6f6d120 100644 --- a/dusken/settings-sample.py +++ b/dusken/settings-sample.py @@ -95,6 +95,7 @@ MIDDLEWARE_CLASSES = ( 'django.contrib.messages.middleware.MessageMiddleware', # Uncomment the next line for simple clickjacking protection: # 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'corsheaders.middleware.CorsMiddleware', ) ROOT_URLCONF = 'dusken.urls' @@ -119,7 +120,8 @@ INSTALLED_APPS = ( 'django.contrib.admindocs', 'dusken', 'tastypie', - 'south' + 'south', + 'corsheaders', ) @@ -152,7 +154,16 @@ LOGGING = { } } -TASTYPIE_DEFAULT_FORMATS = ['json'] # Our own User class used for authentication. AUTH_USER_MODEL = 'dusken.Member' + +TASTYPIE_DEFAULT_FORMATS = ['json'] + +CORS_ORIGIN_ALLOW_ALL = False +# Allow Ajax calls only from tuple of domain:port +CORS_ORIGIN_WHITELIST = ( + '127.0.0.1:3030', + 'dusken.neuf.no:3030', + 'dusken.neuf.no', +) diff --git a/dusken/urls.py b/dusken/urls.py index 5be6189..85ec196 100644 --- a/dusken/urls.py +++ b/dusken/urls.py @@ -2,7 +2,7 @@ from django.conf.urls import patterns, include, url from django.contrib import admin from tastypie.api import Api -from dusken.api.member import MemberResource +from dusken.api.member import MemberResource, MemberCreateResource from dusken.api.membership import MembershipResource from dusken.api.group import GroupResource from dusken.api.institution import InstitutionResource @@ -11,6 +11,7 @@ admin.autodiscover() v1_api = Api(api_name='v1') v1_api.register(MemberResource()) +v1_api.register(MemberCreateResource()) v1_api.register(MembershipResource()) v1_api.register(GroupResource()) v1_api.register(InstitutionResource()) diff --git a/dusken/utils/api.py b/dusken/utils/api.py new file mode 100644 index 0000000..4dc3c98 --- /dev/null +++ b/dusken/utils/api.py @@ -0,0 +1,19 @@ +import string +import random +from django.utils.text import slugify + +def random_string(max_length=15): + return u''.join(random.choice(string.ascii_lowercase + string.digits) for x in range(max_length)) + +def generate_username(data, max_length=15): + username = u"" + # 1. join first and last name + username = u"{}{}".format( + slugify(data.get('first_name', u'')), + slugify(data.get('last_name', u''))) + if not username: + # 2. if no first and last, generate random + # TODO set flag so that unspecified username can be cheched later + username = random_string(max_length) + + return username diff --git a/dusken/views.py b/dusken/views.py index 9821402..4f5a4e7 100644 --- a/dusken/views.py +++ b/dusken/views.py @@ -12,7 +12,9 @@ def authenticate(request): # get apikey and return it user = form.get_user() api_key = user.api_key.key - return HttpResponse(json.dumps({'api_key': api_key}), content_type='application/javascript; charset=utf8') + response = HttpResponse(json.dumps({'api_key': api_key}), content_type='application/javascript; charset=utf8', ) + response['Access-Control-Allow-Origin'] = '*' + return response else: return HttpResponse(json.dumps({'error': 'unauthorized'}), content_type='application/javascript; charset=utf8', status=401) else: diff --git a/requirements.txt b/requirements.txt index 902aeaa..e4b2e5e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,9 @@ Django==1.5.4 -South==0.8.2 -django-tastypie==0.10.0 -psycopg2==2.5.1 +django-cors-headers==0.12 django-extensions==1.2.2 django-sphinxdoc==1.2.0 -sphinxcontrib-httpdomain==1.1.9 +django-tastypie==0.10.0 +psycopg2==2.5.1 python-mimeparse==0.1.4 +South==0.8.2 +sphinxcontrib-httpdomain==1.1.9