diff --git a/ansibleworks/main/pagination.py b/ansibleworks/main/pagination.py new file mode 100644 index 0000000000..de826b0ea1 --- /dev/null +++ b/ansibleworks/main/pagination.py @@ -0,0 +1,37 @@ +# Copyright (c) 2013 AnsibleWorks, Inc. +# All Rights Reserved. + +# Django REST Framework +from rest_framework import serializers, pagination +from rest_framework.templatetags.rest_framework import replace_query_param + +class NextPageField(pagination.NextPageField): + '''Pagination field to output URL path.''' + + def to_native(self, value): + if not value.has_next(): + return None + page = value.next_page_number() + request = self.context.get('request') + url = request and request.get_full_path() or '' + return replace_query_param(url, self.page_field, page) + +class PreviousPageField(pagination.NextPageField): + '''Pagination field to output URL path.''' + + def to_native(self, value): + if not value.has_previous(): + return None + page = value.previous_page_number() + request = self.context.get('request') + url = request and request.get_full_path() or '' + return replace_query_param(url, self.page_field, page) + +class PaginationSerializer(pagination.BasePaginationSerializer): + ''' + Custom pagination serializer to output only URL path (without host/port). + ''' + + count = serializers.Field(source='paginator.count') + next = NextPageField(source='*') + previous = PreviousPageField(source='*') diff --git a/ansibleworks/main/renderers.py b/ansibleworks/main/renderers.py index 69abdcab9c..dc2055eb47 100644 --- a/ansibleworks/main/renderers.py +++ b/ansibleworks/main/renderers.py @@ -1,6 +1,10 @@ -import rest_framework.renderers +# Copyright (c) 2013 AnsibleWorks, Inc. +# All Rights Reserved. -class BrowsableAPIRenderer(rest_framework.renderers.BrowsableAPIRenderer): +# Django REST Framework +from rest_framework import renderers + +class BrowsableAPIRenderer(renderers.BrowsableAPIRenderer): ''' Customizations to the default browsable API renderer. ''' diff --git a/ansibleworks/main/serializers.py b/ansibleworks/main/serializers.py index 21c6ae88ff..2b91a2d30c 100644 --- a/ansibleworks/main/serializers.py +++ b/ansibleworks/main/serializers.py @@ -9,9 +9,8 @@ from django.contrib.auth.models import User from django.core.urlresolvers import reverse from django.core.exceptions import ObjectDoesNotExist -# Django REST framework -from rest_framework import serializers, pagination -from rest_framework.templatetags.rest_framework import replace_query_param +# Django REST Framework +from rest_framework import serializers # AnsibleWorks from ansibleworks.main.models import * @@ -19,37 +18,6 @@ from ansibleworks.main.models import * BASE_FIELDS = ('id', 'url', 'related', 'summary_fields', 'created', 'creation_date', 'name', 'description') -class NextPageField(pagination.NextPageField): - ''' makes the pagination relative URL not full URL ''' - - def to_native(self, value): - if not value.has_next(): - return None - page = value.next_page_number() - request = self.context.get('request') - url = request and request.get_full_path() or '' - return replace_query_param(url, self.page_field, page) - -class PreviousPageField(pagination.NextPageField): - ''' makes the pagination relative URL not full URL ''' - - def to_native(self, value): - if not value.has_previous(): - return None - page = value.previous_page_number() - request = self.context.get('request') - url = request and request.get_full_path() or '' - return replace_query_param(url, self.page_field, page) - -class PaginationSerializer(pagination.BasePaginationSerializer): - ''' - Custom pagination serializer to output only URL path (without host/port). - ''' - - count = serializers.Field(source='paginator.count') - next = NextPageField(source='*') - previous = PreviousPageField(source='*') - # objects that if found we should add summary info for them SUMMARIZABLE_FKS = ( 'organization', 'host', 'group', 'inventory', 'project', 'team', 'job', 'job_template', @@ -142,11 +110,10 @@ class OrganizationSerializer(BaseSerializer): class ProjectSerializer(BaseSerializer): playbooks = serializers.Field(source='playbooks') - local_path_choices = serializers.SerializerMethodField('get_local_path_choices') class Meta: model = Project - fields = BASE_FIELDS + ('local_path', 'local_path_choices') + fields = BASE_FIELDS + ('local_path',) # 'default_playbook', 'scm_type') def get_related(self, obj): @@ -157,9 +124,6 @@ class ProjectSerializer(BaseSerializer): )) return res - def get_local_path_choices(self, obj): - return Project.get_local_path_choices() - class ProjectPlaybooksSerializer(ProjectSerializer): class Meta: diff --git a/ansibleworks/main/tests/projects.py b/ansibleworks/main/tests/projects.py index d59ebdfa99..31366239cc 100644 --- a/ansibleworks/main/tests/projects.py +++ b/ansibleworks/main/tests/projects.py @@ -4,10 +4,12 @@ import datetime import json +from django.conf import settings from django.contrib.auth.models import User as DjangoUser import django.test from django.test.client import Client from django.core.urlresolvers import reverse + from ansibleworks.main.models import * from ansibleworks.main.tests.base import BaseTest @@ -139,6 +141,30 @@ class ProjectsTest(BaseTest): write_test_file(project, 'tasks/blah.yml', TEST_PLAYBOOK) self.assertEqual(len(project.playbooks), 1) + def test_api_config(self): + # superuser can read all config data. + url = reverse('main:api_v1_config_view') + response = self.get(url, expect=200, auth=self.get_super_credentials()) + self.assertTrue('project_base_dir' in response) + self.assertEqual(response['project_base_dir'], settings.PROJECTS_ROOT) + self.assertTrue('project_local_paths' in response) + self.assertEqual(set(response['project_local_paths']), + set(Project.get_local_path_choices())) + + # org admin can read config and will get project fields. + response = self.get(url, expect=200, auth=self.get_normal_credentials()) + self.assertTrue('project_base_dir' in response) + self.assertTrue('project_local_paths' in response) + + # regular user can read configuration, but won't have project fields. + response = self.get(url, expect=200, auth=self.get_nobody_credentials()) + self.assertFalse('project_base_dir' in response) + self.assertFalse('project_local_paths' in response) + + # anonymous/invalid user can't access config. + self.get(url, expect=401) + self.get(url, expect=401, auth=self.get_invalid_credentials()) + def test_mainline(self): # ===================================================================== diff --git a/ansibleworks/main/urls.py b/ansibleworks/main/urls.py index b483d44524..2b5b5636f8 100644 --- a/ansibleworks/main/urls.py +++ b/ansibleworks/main/urls.py @@ -114,6 +114,7 @@ job_events_urls = patterns('ansibleworks.main.views', v1_urls = patterns('ansibleworks.main.views', url(r'^$', 'api_v1_root_view'), + url(r'^config/$', 'api_v1_config_view'), url(r'^authtoken/$', 'auth_token_view'), url(r'^me/$', 'users_me_list'), url(r'^organizations/', include(organizations_urls)), diff --git a/ansibleworks/main/views.py b/ansibleworks/main/views.py index 787f759346..9ac02b546d 100644 --- a/ansibleworks/main/views.py +++ b/ansibleworks/main/views.py @@ -1,32 +1,33 @@ # Copyright (c) 2013 AnsibleWorks, Inc. # All Rights Reserved. -from django.http import HttpResponse -from django.shortcuts import render_to_response -from django.template import RequestContext -from django.views.decorators.csrf import csrf_exempt -from django.shortcuts import get_object_or_404 -from ansibleworks.main.models import * -from django.contrib.auth.models import User -from ansibleworks.main.serializers import * -from ansibleworks.main.rbac import * -from django.core.urlresolvers import reverse -from rest_framework.exceptions import PermissionDenied -from rest_framework import mixins -from rest_framework import generics -from rest_framework import permissions -from rest_framework.response import Response -from rest_framework import status -from rest_framework.settings import api_settings -from rest_framework.authtoken.views import ObtainAuthToken -from rest_framework.views import APIView -import exceptions +# Python import datetime import re import sys -import json as python_json -from base_views import * + +# Django +from django.conf import settings +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse +from django.shortcuts import get_object_or_404, render_to_response +from django.template import RequestContext + +# Django REST Framework +from rest_framework.authtoken.views import ObtainAuthToken +from rest_framework.exceptions import PermissionDenied +from rest_framework import generics +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.settings import api_settings +from rest_framework.views import APIView + +# AnsibleWorks from ansibleworks.main.access import * +from ansibleworks.main.base_views import * +from ansibleworks.main.models import * +from ansibleworks.main.rbac import * +from ansibleworks.main.serializers import * def handle_error(request, status=404): context = {} @@ -93,17 +94,63 @@ class ApiV1RootView(APIView): jobs = reverse('main:job_list'), authtoken = reverse('main:auth_token_view'), me = reverse('main:users_me_list'), + config = reverse('main:api_v1_config_view'), ) return Response(data) +class ApiV1ConfigView(APIView): + ''' + Various sitewide configuration settings (some may only be visible to + superusers or organization admins): + + * `project_base_dir`: Path on the server where projects and playbooks are \ + stored. + * `project_local_paths`: List of directories beneath `project_base_dir` to + use when creating/editing a project. + * `time_zone`: The configured time zone for the server. + ''' + + permission_classes = (IsAuthenticated,) + view_name = 'Configuration' + + def get(self, request, format=None): + '''Return various sitewide configuration settings.''' + + data = dict( + time_zone = settings.TIME_ZONE, + ) + if request.user.is_superuser or request.user.admin_of_organizations.filter(active=True).count(): + data.update(dict( + project_base_dir = settings.PROJECTS_ROOT, + project_local_paths = Project.get_local_path_choices(), + )) + return Response(data) + class AuthTokenView(ObtainAuthToken): ''' - POST username and password to obtain an auth token for subsequent requests. + POST username and password to this resource to obtain an authentication + token for subsequent requests. + + Example JSON to post (application/json): + + {"username": "user", "password": "my pass"} + + Example form data to post (application/x-www-form-urlencoded): + + username=user&password=my%20pass + + If the username and password are valid, the response should be: + + {"token": "8f17825cf08a7efea124f2638f3896f6637f8745"} + + Otherwise, the response will indicate the error that occurred. + + For subsequent requests, pass the token via the HTTP request headers: + + Authenticate: Token 8f17825cf08a7efea124f2638f3896f6637f8745 ''' renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES - # FIXME: Show a better form for HTML view - # FIXME: How to make this view discoverable? class OrganizationsList(BaseList): diff --git a/ansibleworks/settings/defaults.py b/ansibleworks/settings/defaults.py index 08ffdaba96..5f8a88fe9f 100644 --- a/ansibleworks/settings/defaults.py +++ b/ansibleworks/settings/defaults.py @@ -23,22 +23,6 @@ ADMINS = ( MANAGERS = ADMINS -REST_FRAMEWORK = { - 'FILTER_BACKEND': 'ansibleworks.main.custom_filters.CustomFilterBackend', - 'DEFAULT_PAGINATION_SERIALIZER_CLASS': 'ansibleworks.main.serializers.PaginationSerializer', - 'PAGINATE_BY': 25, - 'PAGINATE_BY_PARAM': 'page_size', - 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework.authentication.BasicAuthentication', - 'rest_framework.authentication.TokenAuthentication', - 'rest_framework.authentication.SessionAuthentication', - ), - 'DEFAULT_RENDERER_CLASSES': ( - 'rest_framework.renderers.JSONRenderer', - 'ansibleworks.main.renderers.BrowsableAPIRenderer', - ), -} - DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', @@ -148,6 +132,22 @@ INSTALLED_APPS = ( INTERNAL_IPS = ('127.0.0.1',) +REST_FRAMEWORK = { + 'FILTER_BACKEND': 'ansibleworks.main.custom_filters.CustomFilterBackend', + 'DEFAULT_PAGINATION_SERIALIZER_CLASS': 'ansibleworks.main.pagination.PaginationSerializer', + 'PAGINATE_BY': 25, + 'PAGINATE_BY_PARAM': 'page_size', + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework.authentication.BasicAuthentication', + 'rest_framework.authentication.TokenAuthentication', + 'rest_framework.authentication.SessionAuthentication', + ), + 'DEFAULT_RENDERER_CLASSES': ( + 'rest_framework.renderers.JSONRenderer', + 'ansibleworks.main.renderers.BrowsableAPIRenderer', + ), +} + # Email address that error messages come from. SERVER_EMAIL = 'root@localhost' diff --git a/ansibleworks/templates/rest_framework/api.html b/ansibleworks/templates/rest_framework/api.html index 7f28690944..402907a61d 100644 --- a/ansibleworks/templates/rest_framework/api.html +++ b/ansibleworks/templates/rest_framework/api.html @@ -74,7 +74,8 @@ html body .prettyprint { background: #f5f5f5; } html body .str, -html body .atv { +html body .atv, +html body code { color: #074979; } html body .str a {