From 057e7bad59c848bc0992bce9420cdc06d5fd3aaa Mon Sep 17 00:00:00 2001 From: Chris Church Date: Sun, 4 Aug 2013 20:46:26 -0400 Subject: [PATCH] Update browsable API built-in documentation to use templates. --- awx/main/base_views.py | 153 +++++++++------ awx/main/models/__init__.py | 13 +- awx/main/serializers.py | 54 ++++-- awx/main/templates/main/_list_common.md | 57 ++++++ .../templates/main/_result_fields_common.md | 6 + awx/main/templates/main/api_root_view.md | 4 + awx/main/templates/main/api_v1_config_view.md | 12 ++ awx/main/templates/main/api_v1_root_view.md | 4 + awx/main/templates/main/api_view.md | 1 + awx/main/templates/main/auth_token_view.md | 23 +++ awx/main/templates/main/base_variable_data.md | 9 + .../templates/main/group_all_hosts_list.md | 7 + .../templates/main/host_all_groups_list.md | 7 + .../main/inventory_root_groups_list.md | 7 + .../templates/main/inventory_script_view.md | 11 ++ awx/main/templates/main/job_cancel.md | 10 + awx/main/templates/main/job_list.md | 5 + awx/main/templates/main/job_start.md | 15 ++ .../templates/main/job_template_callback.md | 33 ++++ .../templates/main/job_template_jobs_list.md | 6 + awx/main/templates/main/list_api_view.md | 6 + .../templates/main/list_create_api_view.md | 10 + .../main/organization_admins_list.md | 3 + awx/main/templates/main/project_playbooks.md | 4 + awx/main/templates/main/retrieve_api_view.md | 6 + .../main/retrieve_update_destroy_api_view.md | 18 ++ awx/main/templates/main/sub_list_api_view.md | 7 + .../main/sub_list_create_api_view.md | 37 ++++ .../main/user_admin_of_organizations_list.md | 7 + awx/main/templates/main/user_me_list.md | 7 + awx/main/tests/inventory.py | 8 +- awx/main/tests/projects.py | 2 +- awx/main/urls.py | 8 +- awx/main/utils.py | 14 +- awx/main/views.py | 183 ++++-------------- awx/templates/rest_framework/api.html | 101 ++++++++-- 36 files changed, 606 insertions(+), 252 deletions(-) create mode 100644 awx/main/templates/main/_list_common.md create mode 100644 awx/main/templates/main/_result_fields_common.md create mode 100644 awx/main/templates/main/api_root_view.md create mode 100644 awx/main/templates/main/api_v1_config_view.md create mode 100644 awx/main/templates/main/api_v1_root_view.md create mode 100644 awx/main/templates/main/api_view.md create mode 100644 awx/main/templates/main/auth_token_view.md create mode 100644 awx/main/templates/main/base_variable_data.md create mode 100644 awx/main/templates/main/group_all_hosts_list.md create mode 100644 awx/main/templates/main/host_all_groups_list.md create mode 100644 awx/main/templates/main/inventory_root_groups_list.md create mode 100644 awx/main/templates/main/inventory_script_view.md create mode 100644 awx/main/templates/main/job_cancel.md create mode 100644 awx/main/templates/main/job_list.md create mode 100644 awx/main/templates/main/job_start.md create mode 100644 awx/main/templates/main/job_template_callback.md create mode 100644 awx/main/templates/main/job_template_jobs_list.md create mode 100644 awx/main/templates/main/list_api_view.md create mode 100644 awx/main/templates/main/list_create_api_view.md create mode 100644 awx/main/templates/main/organization_admins_list.md create mode 100644 awx/main/templates/main/project_playbooks.md create mode 100644 awx/main/templates/main/retrieve_api_view.md create mode 100644 awx/main/templates/main/retrieve_update_destroy_api_view.md create mode 100644 awx/main/templates/main/sub_list_api_view.md create mode 100644 awx/main/templates/main/sub_list_create_api_view.md create mode 100644 awx/main/templates/main/user_admin_of_organizations_list.md create mode 100644 awx/main/templates/main/user_me_list.md diff --git a/awx/main/base_views.py b/awx/main/base_views.py index 8c466d3a63..d3ad2c03c8 100644 --- a/awx/main/base_views.py +++ b/awx/main/base_views.py @@ -2,12 +2,14 @@ # All Rights Reserved. # Python +import inspect import json # Django from django.http import HttpResponse, Http404 from django.contrib.auth.models import User from django.shortcuts import get_object_or_404 +from django.template.loader import render_to_string from django.utils.timezone import now # Django REST Framework @@ -15,31 +17,71 @@ from rest_framework.exceptions import PermissionDenied from rest_framework import generics from rest_framework.response import Response from rest_framework import status +from rest_framework import views # AWX from awx.main.models import * +from awx.main.utils import * # FIXME: machinery for auto-adding audit trail logs to all CREATE/EDITS -class ListAPIView(generics.ListAPIView): - # Base class for a read-only list view. +__all__ = ['APIView', 'GenericAPIView', 'ListAPIView', 'ListCreateAPIView', + 'SubListAPIView', 'SubListCreateAPIView', 'RetrieveAPIView', + 'RetrieveUpdateAPIView', 'RetrieveUpdateDestroyAPIView'] + +class APIView(views.APIView): + + def get_description_context(self): + return { + 'docstring': type(self).__doc__ or '', + } + + def get_description(self, html=False): + template_list = [] + for klass in inspect.getmro(type(self)): + template_basename = camelcase_to_underscore(klass.__name__) + template_list.append('main/%s.md' % template_basename) + context = self.get_description_context() + return render_to_string(template_list, context) + +class GenericAPIView(generics.GenericAPIView, APIView): + # Base class for all model-based views. # Subclasses should define: # model = ModelClass # serializer_class = SerializerClass - def get_queryset(self): - return self.request.user.get_queryset(self.model) - - def get_description_vars(self): - return { + def get_description_context(self): + # Set instance attributes needed to get serializer metadata. + if not hasattr(self, 'request'): + self.request = None + if not hasattr(self, 'format_kwarg'): + self.format_kwarg = 'format' + d = super(GenericAPIView, self).get_description_context() + d.update({ 'model_verbose_name': unicode(self.model._meta.verbose_name), 'model_verbose_name_plural': unicode(self.model._meta.verbose_name_plural), - } + 'serializer_fields': self.get_serializer().metadata(), + }) + return d - def get_description(self, html=False): - s = 'Use a GET request to retrieve a list of %(model_verbose_name_plural)s.' - return s % self.get_description_vars() +class ListAPIView(generics.ListAPIView, GenericAPIView): + # Base class for a read-only list view. + + def get_description_context(self): + opts = self.model._meta + if 'username' in opts.get_all_field_names(): + order_field = 'username' + else: + order_field = 'name' + d = super(ListAPIView, self).get_description_context() + d.update({ + 'order_field': order_field, + }) + return d + + def get_queryset(self): + return self.request.user.get_queryset(self.model) class ListCreateAPIView(ListAPIView, generics.ListCreateAPIView): # Base class for a list view that allows creating new objects. @@ -49,11 +91,6 @@ class ListCreateAPIView(ListAPIView, generics.ListCreateAPIView): if isinstance(obj, PrimordialModel): obj.created_by = self.request.user - def get_description(self, html=False): - s = 'Use a GET request to retrieve a list of %(model_verbose_name_plural)s.' - s2 = 'Use a POST request with required %(model_verbose_name)s fields to create a new %(model_verbose_name)s.' - return '\n\n'.join([s, s2]) % self.get_description_vars() - class SubListAPIView(ListAPIView): # Base class for a read-only sublist view. @@ -66,18 +103,14 @@ class SubListAPIView(ListAPIView): # to view sublist): # parent_access = 'read' - def get_description_vars(self): - d = super(SubListAPIView, self).get_description_vars() + def get_description_context(self): + d = super(SubListAPIView, self).get_description_context() d.update({ 'parent_model_verbose_name': unicode(self.parent_model._meta.verbose_name), 'parent_model_verbose_name_plural': unicode(self.parent_model._meta.verbose_name_plural), }) return d - def get_description(self, html=False): - s = 'Use a GET request to retrieve a list of %(model_verbose_name_plural)s associated with the selected %(parent_model_verbose_name)s.' - return s % self.get_description_vars() - def get_parent_object(self): parent_filter = { self.lookup_field: self.kwargs.get(self.lookup_field, None), @@ -109,16 +142,12 @@ class SubListCreateAPIView(SubListAPIView, ListCreateAPIView): # sub_obj requires a foreign key to the parent): # parent_key = 'field_on_model_referring_to_parent' - def get_description(self, html=False): - s = 'Use a GET request to retrieve a list of %(model_verbose_name_plural)s associated with the selected %(parent_model_verbose_name)s.' - s2 = 'Use a POST request with required %(model_verbose_name)s fields to create a new %(model_verbose_name)s.' - if getattr(self, 'parent_key', None): - s3 = 'Use a POST request with an `id` field and `disassociate` set to delete the associated %(model_verbose_name)s.' - s4 = '' - else: - s3 = 'Use a POST request with only an `id` field to associate an existing %(model_verbose_name)s with this %(parent_model_verbose_name)s.' - s4 = 'Use a POST request with an `id` field and `disassociate` set to remove the %(model_verbose_name)s from this %(parent_model_verbose_name)s without deleting the %(model_verbose_name)s.' - return '\n\n'.join(filter(None, [s, s2, s3, s4])) % self.get_description_vars() + def get_description_context(self): + d = super(SubListCreateAPIView, self).get_description_context() + d.update({ + 'parent_key': getattr(self, 'parent_key', None), + }) + return d def create(self, request, *args, **kwargs): # If the object ID was not specified, it probably doesn't exist in the @@ -148,7 +177,8 @@ class SubListCreateAPIView(SubListAPIView, ListCreateAPIView): if not request.user.can_access(self.model, 'add', serializer.init_data): raise PermissionDenied() - # save the object through the serializer, reload and returned the saved object deserialized + # save the object through the serializer, reload and returned the saved + # object deserialized obj = serializer.save() serializer = self.serializer_class(obj) @@ -168,17 +198,19 @@ class SubListCreateAPIView(SubListAPIView, ListCreateAPIView): return response sub_id = response.data['id'] data = response.data + try: + location = response['Location'] + except KeyError: + location = None created = True # Retrive the sub object (whether created or by ID). - try: - sub = self.model.objects.get(pk=sub_id) - except self.model.DoesNotExist: - data = dict(msg='Object with id %s cannot be found' % sub_id) - return Response(data, status=status.HTTP_400_BAD_REQUEST) + sub = get_object_or_400(self.model, pk=sub_id) # Verify we have permission to attach. - if not request.user.can_access(self.parent_model, 'attach', parent, sub, self.relationship, data, skip_sub_obj_read_check=created): + if not request.user.can_access(self.parent_model, 'attach', parent, sub, + self.relationship, data, + skip_sub_obj_read_check=created): raise PermissionDenied() # Attach the object to the collection. @@ -186,7 +218,10 @@ class SubListCreateAPIView(SubListAPIView, ListCreateAPIView): relationship.add(sub) if created: - return Response(data, status=status.HTTP_201_CREATED) + headers = {} + if location: + headers['Location'] = location + return Response(data, status=status.HTTP_201_CREATED, headers=headers) else: return Response(status=status.HTTP_204_NO_CONTENT) @@ -199,14 +234,10 @@ class SubListCreateAPIView(SubListAPIView, ListCreateAPIView): parent = self.get_parent_object() parent_key = getattr(self, 'parent_key', None) relationship = getattr(parent, self.relationship) + sub = get_object_or_400(self.model, pk=sub_id) - try: - sub = self.model.objects.get(pk=sub_id) - except self.model.DoesNotExist: - data = dict(msg='Object with id %s cannot be found' % sub_id) - return Response(data, status=status.HTTP_400_BAD_REQUEST) - - if not request.user.can_access(self.parent_model, 'unattach', parent, sub, self.relationship): + if not request.user.can_access(self.parent_model, 'unattach', parent, + sub, self.relationship): raise PermissionDenied() if parent_key: @@ -224,19 +255,29 @@ class SubListCreateAPIView(SubListAPIView, ListCreateAPIView): else: return self.attach(request, *args, **kwargs) -class RetrieveAPIView(generics.RetrieveAPIView): +class RetrieveAPIView(generics.RetrieveAPIView, GenericAPIView): pass -class RetrieveUpdateDestroyAPIView(RetrieveAPIView, generics.RetrieveUpdateDestroyAPIView): +class RetrieveUpdateAPIView(RetrieveAPIView, generics.RetrieveUpdateAPIView): def pre_save(self, obj): - super(RetrieveUpdateDestroyAPIView, self).pre_save(obj) + super(RetrieveUpdateAPIView, self).pre_save(obj) if isinstance(obj, PrimordialModel): obj.created_by = self.request.user + def update(self, request, *args, **kwargs): + self.update_filter(request, *args, **kwargs) + return super(RetrieveUpdateAPIView, self).update(request, *args, **kwargs) + + def update_filter(self, request, *args, **kwargs): + ''' scrub any fields the user cannot/should not put/patch, based on user context. This runs after read-only serialization filtering ''' + pass + +class RetrieveUpdateDestroyAPIView(RetrieveUpdateAPIView, generics.RetrieveUpdateDestroyAPIView): + def destroy(self, request, *args, **kwargs): # somewhat lame that delete has to call it's own permissions check - obj = self.model.objects.get(pk=kwargs['pk']) + obj = self.get_object() # FIXME: Why isn't the active check being caught earlier by RBAC? if getattr(obj, 'active', True) == False: raise Http404() @@ -247,13 +288,5 @@ class RetrieveUpdateDestroyAPIView(RetrieveAPIView, generics.RetrieveUpdateDestr if hasattr(obj, 'mark_inactive'): obj.mark_inactive() else: - raise Exception("InternalError: destroy() not implemented yet for %s" % obj) + raise NotImplementedError('destroy() not implemented yet for %s' % obj) return HttpResponse(status=204) - - def update(self, request, *args, **kwargs): - self.update_filter(request, *args, **kwargs) - return super(RetrieveUpdateDestroyAPIView, self).update(request, *args, **kwargs) - - def update_filter(self, request, *args, **kwargs): - ''' scrub any fields the user cannot/should not put/patch, based on user context. This runs after read-only serialization filtering ''' - pass diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index 315b7480c2..433a27cae9 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -147,14 +147,23 @@ class Inventory(CommonModel): verbose_name_plural = _('inventories') unique_together = (("name", "organization"),) - organization = models.ForeignKey(Organization, null=False, related_name='inventories') + organization = models.ForeignKey( + Organization, + null=False, + related_name='inventories', + help_text=_('Organization containing this inventory.'), + ) variables = models.TextField( blank=True, default='', null=True, help_text=_('Variables in JSON or YAML format.'), ) - has_active_failures = models.BooleanField(default=False, editable=False) + has_active_failures = models.BooleanField( + default=False, + editable=False, + help_text=_('Flag indicating whether any hosts in this inventory have failed.'), + ) def get_absolute_url(self): return reverse('main:inventory_detail', args=(self.pk,)) diff --git a/awx/main/serializers.py b/awx/main/serializers.py index 4a3bc6f6a0..35ffa17957 100644 --- a/awx/main/serializers.py +++ b/awx/main/serializers.py @@ -11,8 +11,11 @@ import yaml from django.contrib.auth.models import User from django.core.urlresolvers import reverse from django.core.exceptions import ObjectDoesNotExist +from django.utils.datastructures import SortedDict +from django.utils.translation import ugettext_lazy as _ # Django REST Framework +from rest_framework.compat import get_concrete_model from rest_framework import serializers # AWX @@ -34,7 +37,7 @@ SUMMARIZABLE_FIELDS = ( class BaseSerializer(serializers.ModelSerializer): # add the URL and related resources - url = serializers.SerializerMethodField('get_absolute_url') + url = serializers.SerializerMethodField('get_url') related = serializers.SerializerMethodField('get_related') summary_fields = serializers.SerializerMethodField('get_summary_fields') @@ -42,14 +45,34 @@ class BaseSerializer(serializers.ModelSerializer): created = serializers.SerializerMethodField('get_created') active = serializers.SerializerMethodField('get_active') - def get_absolute_url(self, obj): + def get_fields(self): + opts = get_concrete_model(self.opts.model)._meta + ret = super(BaseSerializer, self).get_fields() + for key, field in ret.items(): + if key == 'id' and not getattr(field, 'help_text', None): + field.help_text = 'Database ID for this %s.' % unicode(opts.verbose_name) + elif key == 'url': + field.help_text = 'URL for this %s.' % unicode(opts.verbose_name) + field.type_label = 'string' + elif key == 'related': + field.help_text = 'Data structure with URLs of related resources.' + field.type_label = 'object' + elif key == 'summary_fields': + field.help_text = 'Data structure with name/description for related resources.' + field.type_label = 'object' + elif key == 'created': + field.help_text = 'Timestamp when this %s was created.' % unicode(opts.verbose_name) + field.type_label = 'datetime' + return ret + + def get_url(self, obj): if isinstance(obj, User): return reverse('main:user_detail', args=(obj.pk,)) else: return obj.get_absolute_url() def get_related(self, obj): - res = dict() + res = SortedDict() if getattr(obj, 'created_by', None): res['created_by'] = reverse('main:user_detail', args=(obj.created_by.pk,)) return res @@ -57,12 +80,12 @@ class BaseSerializer(serializers.ModelSerializer): def get_summary_fields(self, obj): # return the names (at least) for various fields, so we don't have to write this # method for each object. - summary_fields = {} + summary_fields = SortedDict() for fk in SUMMARIZABLE_FKS: try: fkval = getattr(obj, fk, None) if fkval is not None: - summary_fields[fk] = {} + summary_fields[fk] = SortedDict() for field in SUMMARIZABLE_FIELDS: fval = getattr(fkval, field, None) if fval is not None: @@ -86,13 +109,13 @@ class BaseSerializer(serializers.ModelSerializer): class UserSerializer(BaseSerializer): - password = serializers.WritableField(required=False, default='') + password = serializers.WritableField(required=False, default='', + help_text='Write-only field used to change the password.') class Meta: model = User fields = ('id', 'url', 'related', 'created', 'username', 'first_name', - 'last_name', 'email', 'is_active', 'is_superuser', - 'password') + 'last_name', 'email', 'is_superuser', 'password') def to_native(self, obj): ret = super(UserSerializer, self).to_native(obj) @@ -152,7 +175,7 @@ class OrganizationSerializer(BaseSerializer): class ProjectSerializer(BaseSerializer): - playbooks = serializers.Field(source='playbooks') + playbooks = serializers.Field(source='playbooks', help_text='Array ') class Meta: model = Project @@ -163,7 +186,7 @@ class ProjectSerializer(BaseSerializer): res.update(dict( organizations = reverse('main:project_organizations_list', args=(obj.pk,)), teams = reverse('main:project_teams_list', args=(obj.pk,)), - playbooks = reverse('main:project_detail_playbooks', args=(obj.pk,)), + playbooks = reverse('main:project_playbooks', args=(obj.pk,)), )) return res @@ -190,7 +213,7 @@ class BaseSerializerWithVariables(BaseSerializer): def validate_variables(self, attrs, source): try: - json.loads(attrs[source].strip() or '{}') + json.loads(attrs.get(source, '').strip() or '{}') except ValueError: try: yaml.safe_load(attrs[source]) @@ -211,8 +234,9 @@ class InventorySerializer(BaseSerializerWithVariables): hosts = reverse('main:inventory_hosts_list', args=(obj.pk,)), groups = reverse('main:inventory_groups_list', args=(obj.pk,)), root_groups = reverse('main:inventory_root_groups_list', args=(obj.pk,)), - variable_data = reverse('main:inventory_variable_detail', args=(obj.pk,)), - organization = reverse('main:organization_detail', args=(obj.organization.pk,)), + variable_data = reverse('main:inventory_variable_data', args=(obj.pk,)), + script = reverse('main:inventory_script_view', args=(obj.pk,)), + organization = reverse('main:organization_detail', args=(obj.organization.pk,)), )) return res @@ -225,7 +249,7 @@ class HostSerializer(BaseSerializerWithVariables): def get_related(self, obj): res = super(HostSerializer, self).get_related(obj) res.update(dict( - variable_data = reverse('main:host_variable_detail', args=(obj.pk,)), + variable_data = reverse('main:host_variable_data', args=(obj.pk,)), inventory = reverse('main:inventory_detail', args=(obj.inventory.pk,)), groups = reverse('main:host_groups_list', args=(obj.pk,)), all_groups = reverse('main:host_all_groups_list', args=(obj.pk,)), @@ -247,7 +271,7 @@ class GroupSerializer(BaseSerializerWithVariables): def get_related(self, obj): res = super(GroupSerializer, self).get_related(obj) res.update(dict( - variable_data = reverse('main:group_variable_detail', args=(obj.pk,)), + variable_data = reverse('main:group_variable_data', args=(obj.pk,)), hosts = reverse('main:group_hosts_list', args=(obj.pk,)), children = reverse('main:group_children_list', args=(obj.pk,)), all_hosts = reverse('main:group_all_hosts_list', args=(obj.pk,)), diff --git a/awx/main/templates/main/_list_common.md b/awx/main/templates/main/_list_common.md new file mode 100644 index 0000000000..89cdbfe741 --- /dev/null +++ b/awx/main/templates/main/_list_common.md @@ -0,0 +1,57 @@ +The resulting data structure contains: + + { + "count": 99, + "next": null, + "previous": null, + "results": [ + ... + ] + } + +The `count` field indicates the total number of {{ model_verbose_name_plural }} +found for the given query. The `next` and `previous` fields provides links to +additional results if there are more than will fit on a single page. The +`results` list contains zero or more {{ model_verbose_name }} records. + +## Results + +Each {{ model_verbose_name }} data structure includes the following fields: + +{% include "main/_result_fields_common.md" %} + +## Sorting + +To specify that {{ model_verbose_name_plural }} are returned in a particular +order, use the `order_by` query string parameter on the GET request. + + ?order_by={{ order_field }} + +Prefix the field name with a dash `-` to sort in reverse: + + ?order_by=-{{ order_field }} + +## Pagination + +Use the `page_size` query string parameter to change the number of results +returned for each request. Use the `page` query string parameter to retrieve +a particular page of results. + + ?page_size=100&page=2 + +The `previous` and `next` links returned with the results will set these query +string parameters automatically. + +## Filtering + +Any additional query string parameters may be used to filter the list of +results returned to those matching a given value. Only fields that exist in +the database can be used for filtering. + + ?{{ order_field }}=value + +Field lookups may also be used for slightly more advanced queries, for example: + + ?{{ order_field }}__startswith=A + ?{{ order_field }}__endsswith=C + ?{{ order_field }}__contains=ABC diff --git a/awx/main/templates/main/_result_fields_common.md b/awx/main/templates/main/_result_fields_common.md new file mode 100644 index 0000000000..453ff6dc70 --- /dev/null +++ b/awx/main/templates/main/_result_fields_common.md @@ -0,0 +1,6 @@ +{% for fn, fm in serializer_fields.items %}{% spaceless %} +{% if not write_only or not fm.read_only %} +* `{{ fn }}`: {{ fm.help_text|capfirst }} ({{ fm.type }}{% if fm.required %}, required{% endif %}{% if fm.read_only %}, read-only{% endif %}) +{% endif %} +{% endspaceless %} +{% endfor %} diff --git a/awx/main/templates/main/api_root_view.md b/awx/main/templates/main/api_root_view.md new file mode 100644 index 0000000000..603002b004 --- /dev/null +++ b/awx/main/templates/main/api_root_view.md @@ -0,0 +1,4 @@ +The root of the AWX REST API. + +Make a GET request to this resource to obtain information about the available +API versions. diff --git a/awx/main/templates/main/api_v1_config_view.md b/awx/main/templates/main/api_v1_config_view.md new file mode 100644 index 0000000000..5d9bd6667e --- /dev/null +++ b/awx/main/templates/main/api_v1_config_view.md @@ -0,0 +1,12 @@ +Site configuration settings and general information. + +Make a GET request to this resource to retrieve the configuration containing +the following fields (some fields may not be visible to all users): + +* `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. +* `license_info`: Information about the current license. +* `version`: Version of AWX package installed. diff --git a/awx/main/templates/main/api_v1_root_view.md b/awx/main/templates/main/api_v1_root_view.md new file mode 100644 index 0000000000..c6d0fcb8c5 --- /dev/null +++ b/awx/main/templates/main/api_v1_root_view.md @@ -0,0 +1,4 @@ +Version 1 of the AWX REST API. + +Make a GET request to this resource to obtain a list of all child resources +available via the API. diff --git a/awx/main/templates/main/api_view.md b/awx/main/templates/main/api_view.md new file mode 100644 index 0000000000..1fb0d77840 --- /dev/null +++ b/awx/main/templates/main/api_view.md @@ -0,0 +1 @@ +{{ docstring }} diff --git a/awx/main/templates/main/auth_token_view.md b/awx/main/templates/main/auth_token_view.md new file mode 100644 index 0000000000..0ea406bdcc --- /dev/null +++ b/awx/main/templates/main/auth_token_view.md @@ -0,0 +1,23 @@ +Make a POST request to this resource with `username` and `password` fields to +obtain an authentication token to use for subsequent requests. + +Example JSON to POST (content type is `application/json`): + + {"username": "user", "password": "my pass"} + +Example form data to post (content type is `application/x-www-form-urlencoded`): + + username=user&password=my%20pass + +If the username and password provided are valid, the response will contain a +`token` field with the authentication token to use: + + {"token": "8f17825cf08a7efea124f2638f3896f6637f8745"} + +Otherwise, the response will indicate the error that occurred and return a 4xx +status code. + +For subsequent requests, pass the token via the HTTP `Authenticate` request +header: + + Authenticate: Token 8f17825cf08a7efea124f2638f3896f6637f8745 diff --git a/awx/main/templates/main/base_variable_data.md b/awx/main/templates/main/base_variable_data.md new file mode 100644 index 0000000000..19994530e0 --- /dev/null +++ b/awx/main/templates/main/base_variable_data.md @@ -0,0 +1,9 @@ +# Retrieve {{ model_verbose_name|title }} Variable Data: + +Make a GET request to this resource to retrieve all variables defined for this +{{ model_verbose_name }}. + +# Update {{ model_verbose_name|title }} Variable Data: + +Make a PUT request to this resource to update variables defined for this +{{ model_verbose_name }}. diff --git a/awx/main/templates/main/group_all_hosts_list.md b/awx/main/templates/main/group_all_hosts_list.md new file mode 100644 index 0000000000..8ce2b209ea --- /dev/null +++ b/awx/main/templates/main/group_all_hosts_list.md @@ -0,0 +1,7 @@ +# List All {{ model_verbose_name_plural|title }} for this {{ parent_model_verbose_name|title }}: + +Make a GET request to this resource to retrieve a list of all +{{ model_verbose_name_plural }} directly or indirectly belonging to this +{{ parent_model_verbose_name }}. + +{% include "main/_list_common.md" %} diff --git a/awx/main/templates/main/host_all_groups_list.md b/awx/main/templates/main/host_all_groups_list.md new file mode 100644 index 0000000000..704f8aefd5 --- /dev/null +++ b/awx/main/templates/main/host_all_groups_list.md @@ -0,0 +1,7 @@ +# List All {{ model_verbose_name_plural|title }} for this {{ parent_model_verbose_name|title }}: + +Make a GET request to this resource to retrieve a list of all +{{ model_verbose_name_plural }} of which the selected +{{ parent_model_verbose_name }} is directly or indirectly a member. + +{% include "main/_list_common.md" %} diff --git a/awx/main/templates/main/inventory_root_groups_list.md b/awx/main/templates/main/inventory_root_groups_list.md new file mode 100644 index 0000000000..025a5e3896 --- /dev/null +++ b/awx/main/templates/main/inventory_root_groups_list.md @@ -0,0 +1,7 @@ +# List Root {{ model_verbose_name_plural|title }} for this {{ parent_model_verbose_name|title }}: + +Make a GET request to this resource to retrieve a list of root (top-level) +{{ model_verbose_name_plural }} associated with this +{{ parent_model_verbose_name }}. + +{% include "main/_list_common.md" %} diff --git a/awx/main/templates/main/inventory_script_view.md b/awx/main/templates/main/inventory_script_view.md new file mode 100644 index 0000000000..2e4a1a5e8d --- /dev/null +++ b/awx/main/templates/main/inventory_script_view.md @@ -0,0 +1,11 @@ +Generate inventory group and host data as needed for an inventory script. + +Make a GET request to this resource without query parameters to retrieve a JSON +object containing groups, including the hosts, children and variables for each +group. The response data is equivalent to that returned by passing the +`--list` argument to an inventory script. + +Make a GET request to this resource with a query string similar to +`?host=HOSTNAME` to retrieve a JSON object containing host variables for the +specified host. The response data is equivalent to that returned by passing +the `--host HOSTNAME` argument to an inventory script. diff --git a/awx/main/templates/main/job_cancel.md b/awx/main/templates/main/job_cancel.md new file mode 100644 index 0000000000..f0acece331 --- /dev/null +++ b/awx/main/templates/main/job_cancel.md @@ -0,0 +1,10 @@ +# Cancel Job + +Make a GET request to this resource to determine if the job can be cancelled. +The response will include the following field: + +* `can_cancel`: Indicates whether this job can be canceled (boolean, read-only) + +Make a POST request to this resource to cancel a pending or running job. The +response status code will be 202 if successful, or 405 if the job cannot be +canceled. diff --git a/awx/main/templates/main/job_list.md b/awx/main/templates/main/job_list.md new file mode 100644 index 0000000000..77aecca1fb --- /dev/null +++ b/awx/main/templates/main/job_list.md @@ -0,0 +1,5 @@ +{% include "main/list_create_api_view.md" %} + +If the `job_template` field is specified, any fields not explicitly provided +for the new job (except `name` and `description`) will use the default values +from the job template. diff --git a/awx/main/templates/main/job_start.md b/awx/main/templates/main/job_start.md new file mode 100644 index 0000000000..91c8f2d9d5 --- /dev/null +++ b/awx/main/templates/main/job_start.md @@ -0,0 +1,15 @@ +# Start Job + +Make a GET request to this resource to determine if the job can be started and +whether any passwords are required to start the job. The response will include +the following fields: + +* `can_start`: Flag indicating if this job can be started (boolean, read-only) +* `passwords_needed_to_start`: Password names required to start the job (array, read-only) + +Make a POST request to this resource to start the job. If any passwords are +required, they must be passed via POST data. + +If successful, the response status code will be 202. If any required passwords +are not provided, a 400 status code will be returned. If the job cannot be +started, a 405 status code will be returned. diff --git a/awx/main/templates/main/job_template_callback.md b/awx/main/templates/main/job_template_callback.md new file mode 100644 index 0000000000..bbd26ffdde --- /dev/null +++ b/awx/main/templates/main/job_template_callback.md @@ -0,0 +1,33 @@ +The job template callback allows for empheral hosts to launch a new job. + +Configure a host to POST to this resource, passing the `host_config_key` +parameter, to start a new job limited to only the requesting host. In the +examples below, replace the `N` parameter with the `id` of the job template +and the `HOST_CONFIG_KEY` with the `host_config_key` associated with the +job template. + +For example, using curl: + + curl --data-urlencode host_config_key=HOST_CONFIG_KEY http://server/api/v1/job_templates/N/callback/ + +Or using wget: + + wget -O /dev/null --post-data="host_config_key=HOST_CONFIG_KEY" http://server/api/v1/job_templates/N/callback/ + +The response will return status 202 if the request is valid, 403 for an +invalid host config key, or 400 if the host cannot be determined from the +address making the request. + +A GET request may be used to verify that the correct host will be selected. +This request must authenticate as a valid user with permission to edit the +job template. For example: + + curl http://user:password@server/api/v1/job_templates/N/callback/ + +The response will include the host config key as well as the host name(s) +that would match the request: + + { + "host_config_key": "HOST_CONFIG_KEY", + "matching_hosts": ["hostname"] + } diff --git a/awx/main/templates/main/job_template_jobs_list.md b/awx/main/templates/main/job_template_jobs_list.md new file mode 100644 index 0000000000..64d8f7f488 --- /dev/null +++ b/awx/main/templates/main/job_template_jobs_list.md @@ -0,0 +1,6 @@ +{% extends "main/sub_list_create_api_view.md" %} + +{% block post_create %} +Any fields not explicitly provided for the new job (except `name` and +`description`) will use the default values from the job template. +{% endblock %} diff --git a/awx/main/templates/main/list_api_view.md b/awx/main/templates/main/list_api_view.md new file mode 100644 index 0000000000..227bf5eada --- /dev/null +++ b/awx/main/templates/main/list_api_view.md @@ -0,0 +1,6 @@ +# List {{ model_verbose_name_plural|title }}: + +Make a GET request to this resource to retrieve the list of +{{ model_verbose_name_plural }}. + +{% include "main/_list_common.md" %} diff --git a/awx/main/templates/main/list_create_api_view.md b/awx/main/templates/main/list_create_api_view.md new file mode 100644 index 0000000000..7554fa983f --- /dev/null +++ b/awx/main/templates/main/list_create_api_view.md @@ -0,0 +1,10 @@ +{% include "main/list_api_view.md" %} + +# Create {{ model_verbose_name_plural|title }}: + +Make a POST request to this resource with the following {{ model_verbose_name }} +fields to create a new {{ model_verbose_name }}: + +{% with write_only=1 %} +{% include "main/_result_fields_common.md" %} +{% endwith %} diff --git a/awx/main/templates/main/organization_admins_list.md b/awx/main/templates/main/organization_admins_list.md new file mode 100644 index 0000000000..7b055379c6 --- /dev/null +++ b/awx/main/templates/main/organization_admins_list.md @@ -0,0 +1,3 @@ +{% with model_verbose_name="admin user" model_verbose_name_plural="admin users" %} +{% include "main/sub_list_create_api_view.md" %} +{% endwith %} \ No newline at end of file diff --git a/awx/main/templates/main/project_playbooks.md b/awx/main/templates/main/project_playbooks.md new file mode 100644 index 0000000000..7b319258d0 --- /dev/null +++ b/awx/main/templates/main/project_playbooks.md @@ -0,0 +1,4 @@ +# Retrieve {{ model_verbose_name|title }} Playbooks: + +Make GET request to this resource to retrieve a list of playbooks available +for this {{ model_verbose_name }}. diff --git a/awx/main/templates/main/retrieve_api_view.md b/awx/main/templates/main/retrieve_api_view.md new file mode 100644 index 0000000000..099b5865e3 --- /dev/null +++ b/awx/main/templates/main/retrieve_api_view.md @@ -0,0 +1,6 @@ +# Retrieve {{ model_verbose_name|title }}: + +Make GET request to this resource to retrieve a single {{ model_verbose_name }} +record containing the following fields: + +{% include "main/_result_fields_common.md" %} diff --git a/awx/main/templates/main/retrieve_update_destroy_api_view.md b/awx/main/templates/main/retrieve_update_destroy_api_view.md new file mode 100644 index 0000000000..79afeeb141 --- /dev/null +++ b/awx/main/templates/main/retrieve_update_destroy_api_view.md @@ -0,0 +1,18 @@ +{% include "main/retrieve_api_view.md" %} + +# Update {{ model_verbose_name|title }}: + +Make a PUT or PATCH request to this resource to update this +{{ model_verbose_name }}. The following fields may be modified: + +{% with write_only=1 %} +{% include "main/_result_fields_common.md" %} +{% endwith %} + +For a PUT request, include **all** fields in the request. + +For a PATCH request, include only the fields that are being modified. + +# Delete {{ model_verbose_name|title }}: + +Make a DELETE request to this resource to delete this {{ model_verbose_name }}. diff --git a/awx/main/templates/main/sub_list_api_view.md b/awx/main/templates/main/sub_list_api_view.md new file mode 100644 index 0000000000..ca99964000 --- /dev/null +++ b/awx/main/templates/main/sub_list_api_view.md @@ -0,0 +1,7 @@ +# List {{ model_verbose_name_plural|title }} for this {{ parent_model_verbose_name|title }}: + +Make a GET request to this resource to retrieve a list of +{{ model_verbose_name_plural }} associated with the selected +{{ parent_model_verbose_name }}. + +{% include "main/_list_common.md" %} diff --git a/awx/main/templates/main/sub_list_create_api_view.md b/awx/main/templates/main/sub_list_create_api_view.md new file mode 100644 index 0000000000..9634ca6650 --- /dev/null +++ b/awx/main/templates/main/sub_list_create_api_view.md @@ -0,0 +1,37 @@ +{% include "main/sub_list_api_view.md" %} + +# Create {{ model_verbose_name_plural|title }} for this {{ parent_model_verbose_name|title }}: + +Make a POST request to this resource with the following {{ model_verbose_name }} +fields to create a new {{ model_verbose_name }} associated with this +{{ parent_model_verbose_name }}. + +{% with write_only=1 %} +{% include "main/_result_fields_common.md" %} +{% endwith %} + +{% block post_create %}{% endblock %} + +{% if parent_key %} +# Remove {{ parent_model_verbose_name|title }} {{ model_verbose_name_plural|title }}: + +Make a POST request to this resource with `id` and `disassociate` fields to +delete the associated {{ model_verbose_name }}. + + { + "id": 123, + "disassociate": true + } + +{% else %} +# Add {{ model_verbose_name_plural|title }} for this {{ parent_model_verbose_name|title }}: + +Make a POST request to this resource with only an `id` field to associate an +existing {{ model_verbose_name }} with this {{ parent_model_verbose_name }}. + +# Remove {{ model_verbose_name_plural|title }} from this {{ parent_model_verbose_name|title }}: + +Make a POST request to this resource with `id` and `disassociate` fields to +remove the {{ model_verbose_name }} from this {{ parent_model_verbose_name }} +without deleting the {{ model_verbose_name }}. +{% endif %} diff --git a/awx/main/templates/main/user_admin_of_organizations_list.md b/awx/main/templates/main/user_admin_of_organizations_list.md new file mode 100644 index 0000000000..9596982793 --- /dev/null +++ b/awx/main/templates/main/user_admin_of_organizations_list.md @@ -0,0 +1,7 @@ +# List {{ model_verbose_name_plural|title }} Administered by this {{ parent_model_verbose_name|title }}: + +Make a GET request to this resource to retrieve a list of +{{ model_verbose_name_plural }} of which the selected +{{ parent_model_verbose_name }} is an admin. + +{% include "main/_list_common.md" %} diff --git a/awx/main/templates/main/user_me_list.md b/awx/main/templates/main/user_me_list.md new file mode 100644 index 0000000000..577b890a72 --- /dev/null +++ b/awx/main/templates/main/user_me_list.md @@ -0,0 +1,7 @@ +Make a GET request to retrieve user information about the current user. + +One result should be returned containing the following fields: + +{% include "main/_result_fields_common.md" %} + +Use the primary URL for the user (/api/v1/users/N/) to modify the user. diff --git a/awx/main/tests/inventory.py b/awx/main/tests/inventory.py index f2d1473b27..44f8c48770 100644 --- a/awx/main/tests/inventory.py +++ b/awx/main/tests/inventory.py @@ -342,7 +342,7 @@ class InventoryTest(BaseTest): vars_c = dict(asdf=5555, dog='mouse', cat='mogwai', unstructured=dict(a=[3,0,3],b=dict(z=2600))) # attempting to get a variable object creates it, even though it does not already exist - vdata_url = reverse('main:host_variable_detail', args=(added_by_collection_a['id'],)) + vdata_url = reverse('main:host_variable_data', args=(added_by_collection_a['id'],)) got = self.get(vdata_url, expect=200, auth=self.get_super_credentials()) self.assertEquals(got, {}) @@ -373,8 +373,8 @@ class InventoryTest(BaseTest): vars_c = dict(asdf=9999, dog='pluto', cat='five', unstructured=dict(a=[3,3,3],b=dict(z=5))) groups = Group.objects.all() - vdata1_url = reverse('main:group_variable_detail', args=(groups[0].pk,)) - vdata2_url = reverse('main:group_variable_detail', args=(groups[1].pk,)) + vdata1_url = reverse('main:group_variable_data', args=(groups[0].pk,)) + vdata2_url = reverse('main:group_variable_data', args=(groups[1].pk,)) # a super user can associate variable objects with groups got = self.get(vdata1_url, expect=200, auth=self.get_super_credentials()) @@ -399,7 +399,7 @@ class InventoryTest(BaseTest): vars_b = dict(asdf=2736, dog='benji', cat='garfield', unstructured=dict(a=[2,2,2],b=dict(x=3,y=4))) vars_c = dict(asdf=7692, dog='buck', cat='sylvester', unstructured=dict(a=[3,3,3],b=dict(z=5))) - vdata_url = reverse('main:inventory_variable_detail', args=(self.inventory_a.pk,)) + vdata_url = reverse('main:inventory_variable_data', args=(self.inventory_a.pk,)) # a super user can associate variable objects with inventory got = self.get(vdata_url, expect=200, auth=self.get_super_credentials()) diff --git a/awx/main/tests/projects.py b/awx/main/tests/projects.py index 59f70c8988..3695b75773 100644 --- a/awx/main/tests/projects.py +++ b/awx/main/tests/projects.py @@ -235,7 +235,7 @@ class ProjectsTest(BaseTest): self.get(project, expect=404, auth=self.get_normal_credentials()) # can list playbooks for projects - proj_playbooks = reverse('main:project_detail_playbooks', args=(self.projects[2].pk,)) + proj_playbooks = reverse('main:project_playbooks', args=(self.projects[2].pk,)) got = self.get(proj_playbooks, expect=200, auth=self.get_super_credentials()) self.assertEqual(got, self.projects[2].playbooks) diff --git a/awx/main/urls.py b/awx/main/urls.py index d9eca0a57e..07dd75cd60 100644 --- a/awx/main/urls.py +++ b/awx/main/urls.py @@ -33,7 +33,7 @@ user_urls = patterns('awx.main.views', project_urls = patterns('awx.main.views', url(r'^$', 'project_list'), url(r'^(?P[0-9]+)/$', 'project_detail'), - url(r'^(?P[0-9]+)/playbooks/$', 'project_detail_playbooks'), + url(r'^(?P[0-9]+)/playbooks/$', 'project_playbooks'), url(r'^(?P[0-9]+)/organizations/$', 'project_organizations_list'), url(r'^(?P[0-9]+)/teams/$', 'project_teams_list'), ) @@ -53,14 +53,14 @@ inventory_urls = patterns('awx.main.views', url(r'^(?P[0-9]+)/hosts/$', 'inventory_hosts_list'), url(r'^(?P[0-9]+)/groups/$', 'inventory_groups_list'), url(r'^(?P[0-9]+)/root_groups/$', 'inventory_root_groups_list'), - url(r'^(?P[0-9]+)/variable_data/$', 'inventory_variable_detail'), + url(r'^(?P[0-9]+)/variable_data/$', 'inventory_variable_data'), url(r'^(?P[0-9]+)/script/$', 'inventory_script_view'), ) host_urls = patterns('awx.main.views', url(r'^$', 'host_list'), url(r'^(?P[0-9]+)/$', 'host_detail'), - url(r'^(?P[0-9]+)/variable_data/$', 'host_variable_detail'), + url(r'^(?P[0-9]+)/variable_data/$', 'host_variable_data'), url(r'^(?P[0-9]+)/groups/$', 'host_groups_list'), url(r'^(?P[0-9]+)/all_groups/$', 'host_all_groups_list'), url(r'^(?P[0-9]+)/job_events/', 'host_job_events_list'), @@ -73,7 +73,7 @@ group_urls = patterns('awx.main.views', url(r'^(?P[0-9]+)/children/$', 'group_children_list'), url(r'^(?P[0-9]+)/hosts/$', 'group_hosts_list'), url(r'^(?P[0-9]+)/all_hosts/$', 'group_all_hosts_list'), - url(r'^(?P[0-9]+)/variable_data/$', 'group_variable_detail'), + url(r'^(?P[0-9]+)/variable_data/$', 'group_variable_data'), url(r'^(?P[0-9]+)/job_events/$', 'group_job_events_list'), url(r'^(?P[0-9]+)/job_host_summaries/$', 'group_job_host_summaries_list'), ) diff --git a/awx/main/utils.py b/awx/main/utils.py index e8d900769e..f489138985 100644 --- a/awx/main/utils.py +++ b/awx/main/utils.py @@ -6,7 +6,8 @@ import sys # Django REST Framework from rest_framework.exceptions import ParseError, PermissionDenied -__all__ = ['get_object_or_400', 'get_object_or_403', 'camelcase_to_underscore'] +__all__ = ['get_object_or_400', 'get_object_or_403', 'camelcase_to_underscore', + 'get_awx_version'] def get_object_or_400(klass, *args, **kwargs): ''' @@ -51,3 +52,14 @@ class RequireDebugTrueOrTest(logging.Filter): def filter(self, record): from django.conf import settings return settings.DEBUG or 'test' in sys.argv + +def get_awx_version(): + ''' + Return AWX version as reported by setuptools. + ''' + from awx import __version__ + try: + import pkg_resources + return pkg_resources.require('awx')[0].version + except: + return __version__ diff --git a/awx/main/views.py b/awx/main/views.py index c4f575a04a..fc40bdb834 100644 --- a/awx/main/views.py +++ b/awx/main/views.py @@ -13,17 +13,17 @@ 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 +from django.utils.datastructures import SortedDict # Django REST Framework from rest_framework.authtoken.views import ObtainAuthToken from rest_framework.exceptions import PermissionDenied -from rest_framework import generics from rest_framework.parsers import YAMLParser from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.renderers import YAMLRenderer from rest_framework.response import Response from rest_framework.settings import api_settings -from rest_framework.views import APIView +from rest_framework import status # AWX from awx.main.authentication import JobTaskAuthentication @@ -54,10 +54,6 @@ def handle_500(request): return handle_error(request, 500) class ApiRootView(APIView): - ''' - This resource is the root of the AWX REST API and provides - information about the available API versions. - ''' permission_classes = (AllowAny,) view_name = 'REST API' @@ -76,11 +72,6 @@ class ApiRootView(APIView): return Response(data) class ApiV1RootView(APIView): - ''' - Version 1 of the REST API. - - Subject to change until the final 1.2 release. - ''' permission_classes = (AllowAny,) view_name = 'Version 1' @@ -106,17 +97,6 @@ class ApiV1RootView(APIView): 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. - * `license_info`: Information about the current license. - ''' permission_classes = (IsAuthenticated,) view_name = 'Configuration' @@ -130,6 +110,7 @@ class ApiV1ConfigView(APIView): data = dict( time_zone=settings.TIME_ZONE, license_info=license_data, + version=get_awx_version(), ) if request.user.is_superuser or request.user.admin_of_organizations.filter(active=True).count(): data.update(dict( @@ -139,29 +120,7 @@ class ApiV1ConfigView(APIView): return Response(data) -class AuthTokenView(ObtainAuthToken): - ''' - 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 - ''' +class AuthTokenView(ObtainAuthToken, APIView): permission_classes = (AllowAny,) renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES @@ -273,7 +232,7 @@ class ProjectDetail(RetrieveUpdateDestroyAPIView): model = Project serializer_class = ProjectSerializer -class ProjectDetailPlaybooks(RetrieveAPIView): +class ProjectPlaybooks(RetrieveAPIView): model = Project serializer_class = ProjectPlaybooksSerializer @@ -301,8 +260,7 @@ class UserMeList(ListAPIView): model = User serializer_class = UserSerializer - - view_name = 'Me!' + view_name = 'Me' def get_queryset(self): return self.model.objects.filter(pk=self.request.user.pk) @@ -522,37 +480,28 @@ class InventoryRootGroupsList(SubListCreateAPIView): sublist_qs = parent.groups.exclude(parents__pk__in=all_pks).distinct() return qs & sublist_qs -class BaseVariableDetail(RetrieveUpdateDestroyAPIView): +class BaseVariableData(RetrieveUpdateAPIView): parser_classes = api_settings.DEFAULT_PARSER_CLASSES + [YAMLParser] renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [YAMLRenderer] is_variable_data = True # Special flag for permissions check. -class InventoryVariableDetail(BaseVariableDetail): +class InventoryVariableData(BaseVariableData): model = Inventory serializer_class = InventoryVariableDataSerializer -class HostVariableDetail(BaseVariableDetail): +class HostVariableData(BaseVariableData): model = Host serializer_class = HostVariableDataSerializer -class GroupVariableDetail(BaseVariableDetail): +class GroupVariableData(BaseVariableData): model = Group serializer_class = GroupVariableDataSerializer class InventoryScriptView(RetrieveAPIView): - ''' - Return inventory group and host data as needed for an inventory script. - - Without query parameters, return groups with hosts, children and vars - (equivalent to the --list parameter to an inventory script). - - With ?host=HOSTNAME, return host vars for the given host (equivalent to the - --host HOSTNAME parameter to an inventory script). - ''' model = Inventory authentication_classes = [JobTaskAuthentication] + \ @@ -568,48 +517,30 @@ class InventoryScriptView(RetrieveAPIView): name=hostname) data = host.variables_dict else: - data = {} + data = SortedDict() + if self.object.variables_dict: + data['all'] = SortedDict() + data['all']['vars'] = self.object.variables_dict + for group in self.object.groups.filter(active=True): hosts = group.hosts.filter(active=True) children = group.children.filter(active=True) - group_info = { - 'hosts': list(hosts.values_list('name', flat=True)), - 'children': list(children.values_list('name', flat=True)), - 'vars': group.variables_dict, - } + group_info = SortedDict() + group_info['hosts'] = list(hosts.values_list('name', flat=True)) + group_info['children'] = list(children.values_list('name', flat=True)) + group_info['vars'] = group.variables_dict + data[group.name] = group_info - # this confuses the inventory script if the group - # has children set and no variables or hosts. - # no other reason to do this right? - # - # group_info = dict(filter(lambda x: bool(x[1]), - # group_info.items())) - - if group_info.keys() in ([], ['hosts']): - data[group.name] = group_info.get('hosts', []) - else: - data[group.name] = group_info - - if self.object.variables_dict: - data['all'] = { - 'vars': self.object.variables_dict, - } - - - # workaround for Ansible inventory bug (github #3687), localhost must - # be explicitly listed in the all group for dynamic inventory - # scripts to pick it up - - localhost = Host.objects.filter(inventory=self.object, name='localhost').count() - localhost2 = Host.objects.filter(inventory=self.object, name='127.0.0.1').count() - if localhost or localhost2: - if not 'all' in data: - data['all'] = {} - data['all']['hosts'] = [] - if localhost: - data['all']['hosts'].append('localhost') - if localhost2: - data['all']['hosts'].append('127.0.0.1') + # workaround for Ansible inventory bug (github #3687), localhost + # must be explicitly listed in the all group for dynamic inventory + # scripts to pick it up. + localhost_names = ('localhost', '127.0.0.1', '::1') + localhosts_qs = self.object.hosts.filter(active=True, + name__in=localhost_names) + localhosts = list(localhosts_qs.values_list('name', flat=True)) + if localhosts: + data.setdefault('all', SortedDict()) + data['all']['hosts'] = localhosts return Response(data) @@ -618,51 +549,12 @@ class JobTemplateList(ListCreateAPIView): model = JobTemplate serializer_class = JobTemplateSerializer - def _get_queryset(self): - return self.request.user.get_queryset(self.model) - class JobTemplateDetail(RetrieveUpdateDestroyAPIView): model = JobTemplate serializer_class = JobTemplateSerializer -class JobTemplateCallback(generics.GenericAPIView): - ''' - The job template callback allows for empheral hosts to launch a new job. - - Configure a host to POST to this resource, passing the `host_config_key` - parameter, to start a new job limited to only the requesting host. In the - examples below, replace the `N` parameter with the `id` of the job template - and the `HOST_CONFIG_KEY` with the `host_config_key` associated with the - job template. - - For example, using curl: - - curl --data-urlencode host_config_key=HOST_CONFIG_KEY http://server/api/v1/job_templates/N/callback/ - - Or using wget: - - wget -O /dev/null --post-data="host_config_key=HOST_CONFIG_KEY" http://server/api/v1/job_templates/N/callback/ - - The response will return status 202 if the request is valid, 403 for an - invalid host config key, or 400 if the host cannot be determined from the - address making the request. - - A GET request may be used to verify that the correct host will be selected. - This request must authenticate as a valid user with permission to edit the - job template. For example: - - curl http://user:password@server/api/v1/job_templates/N/callback/ - - The response will include the host config key as well as the host name(s) - that would match the request: - - { - "host_config_key": "HOST_CONFIG_KEY", - "matching_hosts": ["hostname"] - } - - ''' +class JobTemplateCallback(GenericAPIView): model = JobTemplate permission_classes = (JobTemplateCallbackPermission,) @@ -782,9 +674,6 @@ class JobList(ListCreateAPIView): model = Job serializer_class = JobSerializer - def _get_queryset(self): - return self.model.objects.all() # FIXME - class JobDetail(RetrieveUpdateDestroyAPIView): model = Job @@ -797,7 +686,7 @@ class JobDetail(RetrieveUpdateDestroyAPIView): return self.http_method_not_allowed(request, *args, **kwargs) return super(JobDetail, self).update(request, *args, **kwargs) -class JobStart(generics.GenericAPIView): +class JobStart(GenericAPIView): model = Job is_job_start = True @@ -823,7 +712,7 @@ class JobStart(generics.GenericAPIView): else: return self.http_method_not_allowed(request, *args, **kwargs) -class JobCancel(generics.GenericAPIView): +class JobCancel(GenericAPIView): model = Job is_job_cancel = True @@ -849,8 +738,7 @@ class BaseJobHostSummariesList(SubListAPIView): serializer_class = JobHostSummarySerializer parent_model = None # Subclasses must define this attribute. relationship = 'job_host_summaries' - - view_name = 'Job Host Summary List' + view_name = 'Job Host Summaries List' class HostJobHostSummariesList(BaseJobHostSummariesList): @@ -885,7 +773,6 @@ class JobEventChildrenList(SubListAPIView): serializer_class = JobEventSerializer parent_model = JobEvent relationship = 'children' - view_name = 'Job Event Children List' class JobEventHostsList(SubListAPIView): @@ -894,7 +781,6 @@ class JobEventHostsList(SubListAPIView): serializer_class = HostSerializer parent_model = JobEvent relationship = 'hosts' - view_name = 'Job Event Hosts List' class BaseJobEventsList(SubListAPIView): @@ -903,6 +789,7 @@ class BaseJobEventsList(SubListAPIView): serializer_class = JobEventSerializer parent_model = None # Subclasses must define this attribute. relationship = 'job_events' + view_name = 'Job Events List' class HostJobEventsList(BaseJobEventsList): diff --git a/awx/templates/rest_framework/api.html b/awx/templates/rest_framework/api.html index 1f8637bfa2..064f012611 100644 --- a/awx/templates/rest_framework/api.html +++ b/awx/templates/rest_framework/api.html @@ -12,18 +12,18 @@ html body { } html body .navbar .navbar-inner { border-top: none; - height: 20px; + height: 54px; } html body .navbar-inverse .navbar-inner { - background-color: #36454F; - background-image: -moz-linear-gradient(top, #36454F, #36454F); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#36454F), to(#36454F)); - background-image: -webkit-linear-gradient(top, #36454F, #36454F); - background-image: -o-linear-gradient(top, #36454F, #36454F); - background-image: linear-gradient(to bottom, #36454F, #36454F); + background-color: #171717; + background-image: -moz-linear-gradient(top, #171717, #171717); + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#171717), to(#171717)); + background-image: -webkit-linear-gradient(top, #171717, #171717); + background-image: -o-linear-gradient(top, #171717, #171717); + background-image: linear-gradient(to bottom, #171717, #171717); background-repeat: repeat-x; - border-color: #36454F; - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#36454F', endColorstr='#36454F', GradientType=0); + border-color: #171717; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#171717', endColorstr='#171717', GradientType=0); } html body .navbar-inverse .nav > li > a { color: #A9A9A9; @@ -33,7 +33,7 @@ html body .navbar-inverse .nav > li > a:focus { color: #2078be; } html body .navbar .brand img { - width: 130px; + width: 200px; margin-top: -6px; margin-right: 0.5em; } @@ -45,6 +45,15 @@ html body .navbar-inverse .nav li.dropdown.open.active > .dropdown-toggle { html body .navbar-inverse .brand { font-size: 1.2em; color: #fff; + padding-top: 6px; + padding-left: 4px; +} +html body .navbar-inverse .nav.pull-right { + margin-top: 8px; + margin-right: -8px; +} +html body ul.breadcrumb { + margin-top: 72px; } span.powered-by .version { color: #ddd; @@ -88,20 +97,35 @@ html body .description { padding-bottom: 0; display: none; } -.footer { - margin-top: 0.5em; +#footer { font-size: 0.8em; text-align: center; + padding-bottom: 1em; } -.footer a, -.footer a:hover { +#footer a, +#footer a:hover { color: #333; } +img.awxlogo { + width: 125px; + margin-bottom: 0.5em; +} +html body .wrapper { + min-height: 1%; + height: auto !important; + margin: 0 auto 0; +} +html body #footer { + height: auto !important; +} +html body #push { + height: 0; +} {% endblock %} {% block branding %} - {% trans 'REST API' %} + {% trans 'REST API' %} {% endblock %} {% block userlinks %} @@ -113,7 +137,9 @@ html body .description { {% endblock %} {% block footer %} -