1
0
mirror of https://github.com/ansible/awx.git synced 2024-10-27 00:55:06 +03:00

add support for building swagger/OpenAPI JSON

to build, run `make swagger`; a file named `swagger.json` will be
written to the current working directory
This commit is contained in:
Ryan Petrello 2018-02-01 10:52:14 -05:00
parent c61efc0af8
commit 57c22c20b2
No known key found for this signature in database
GPG Key ID: F2AA5F2122351777
35 changed files with 379 additions and 29 deletions

View File

@ -363,6 +363,12 @@ pyflakes: reports
pylint: reports
@(set -o pipefail && $@ | reports/$@.report)
swagger: reports
@if [ "$(VENV_BASE)" ]; then \
. $(VENV_BASE)/awx/bin/activate; \
fi; \
(set -o pipefail && py.test awx/main/tests/docs --release=$(RELEASE_VERSION) | tee reports/$@.report)
check: flake8 pep8 # pyflakes pylint
TEST_DIRS ?= awx/main/tests/unit awx/main/tests/functional awx/conf/tests awx/sso/tests

View File

@ -28,6 +28,7 @@ from rest_framework import status
from rest_framework import views
# AWX
from awx.api.swagger import AutoSchema
from awx.api.filters import FieldLookupBackend
from awx.main.models import * # noqa
from awx.main.access import access_registry
@ -93,6 +94,7 @@ def get_view_description(cls, request, html=False):
class APIView(views.APIView):
schema = AutoSchema()
versioning_class = URLPathVersioning
def initialize_request(self, request, *args, **kwargs):
@ -176,7 +178,7 @@ class APIView(views.APIView):
and in the browsable API.
"""
func = self.settings.VIEW_DESCRIPTION_FUNCTION
return func(self.__class__, self._request, html)
return func(self.__class__, getattr(self, '_request', None), html)
def get_description_context(self):
return {
@ -197,6 +199,7 @@ class APIView(views.APIView):
'new_in_330': getattr(self, 'new_in_330', False),
'new_in_api_v2': getattr(self, 'new_in_api_v2', False),
'deprecated': getattr(self, 'deprecated', False),
'swagger_method': getattr(self.request, 'swagger_method', None),
}
def get_description(self, request, html=False):

89
awx/api/swagger.py Normal file
View File

@ -0,0 +1,89 @@
import warnings
from coreapi.document import Object, Link
from rest_framework import exceptions
from rest_framework.permissions import AllowAny
from rest_framework.renderers import CoreJSONRenderer
from rest_framework.response import Response
from rest_framework.schemas import SchemaGenerator, AutoSchema as DRFAuthSchema
from rest_framework.views import APIView
from rest_framework_swagger import renderers
class AutoSchema(DRFAuthSchema):
def get_link(self, path, method, base_url):
link = super(AutoSchema, self).get_link(path, method, base_url)
try:
serializer = self.view.get_serializer()
except Exception:
serializer = None
warnings.warn('{}.get_serializer() raised an exception during '
'schema generation. Serializer fields will not be '
'generated for {} {}.'
.format(self.view.__class__.__name__, method, path))
# auto-generate a topic/tag for the serializer based on its model
if hasattr(self.view, 'swagger_topic'):
link.__dict__['topic'] = str(self.view.swagger_topic).title()
elif serializer and hasattr(serializer, 'Meta'):
link.__dict__['topic'] = str(
serializer.Meta.model._meta.verbose_name_plural
).title()
elif hasattr(self.view, 'model'):
link.__dict__['topic'] = str(self.view.model._meta.verbose_name_plural).title()
else:
warnings.warn('Could not determine a Swagger tag for path {}'.format(path))
return link
def get_description(self, path, method):
self.view._request = self.view.request
setattr(self.view.request, 'swagger_method', method)
description = super(AutoSchema, self).get_description(path, method)
return description
class SwaggerSchemaView(APIView):
_ignore_model_permissions = True
exclude_from_schema = True
permission_classes = [AllowAny]
renderer_classes = [
CoreJSONRenderer,
renderers.OpenAPIRenderer,
renderers.SwaggerUIRenderer
]
def get(self, request):
generator = SchemaGenerator(
title='Ansible Tower API',
patterns=None,
urlconf=None
)
schema = generator.get_schema(request=request)
# By default, DRF OpenAPI serialization places all endpoints in
# a single node based on their root path (/api). Instead, we want to
# group them by topic/tag so that they're categorized in the rendered
# output
document = schema._data.pop('api')
for path, node in document.items():
if isinstance(node, Object):
for action in node.values():
topic = getattr(action, 'topic', None)
if topic:
schema._data.setdefault(topic, Object())
schema._data[topic]._data[path] = node
elif isinstance(node, Link):
topic = getattr(node, 'topic', None)
if topic:
schema._data.setdefault(topic, Object())
schema._data[topic]._data[path] = node
if not schema:
raise exceptions.ValidationError(
'The schema generator did not return a schema Document'
)
return Response(schema)

View File

@ -1,9 +1,13 @@
{% ifmeth GET %}
# Retrieve {{ model_verbose_name|title }} Variable Data:
Make a GET request to this resource to retrieve all variables defined for this
Make a GET request to this resource to retrieve all variables defined for a
{{ model_verbose_name }}.
{% endifmeth %}
{% ifmeth PUT PATCH %}
# Update {{ model_verbose_name|title }} Variable Data:
Make a PUT request to this resource to update variables defined for this
Make a PUT or PATCH request to this resource to update variables defined for a
{{ model_verbose_name }}.
{% endifmeth %}

View File

@ -1,4 +1,4 @@
# List All {{ model_verbose_name_plural|title }} for this {{ parent_model_verbose_name|title }}:
# List All {{ model_verbose_name_plural|title }} for {{ parent_model_verbose_name|title|anora }}:
Make a GET request to this resource to retrieve a list of all
{{ model_verbose_name_plural }} directly or indirectly belonging to this

View File

@ -1,4 +1,4 @@
# List Potential Child Groups for this {{ parent_model_verbose_name|title }}:
# List Potential Child Groups for {{ parent_model_verbose_name|title|anora }}:
Make a GET request to this resource to retrieve a list of
{{ model_verbose_name_plural }} available to be added as children of the

View File

@ -1,4 +1,4 @@
# List All {{ model_verbose_name_plural|title }} for this {{ parent_model_verbose_name|title }}:
# List All {{ model_verbose_name_plural|title }} for {{ parent_model_verbose_name|title|anora }}:
Make a GET request to this resource to retrieve a list of all
{{ model_verbose_name_plural }} of which the selected

View File

@ -1,3 +1,5 @@
# List Fact Scans for a Host Specific Host Scan
Make a GET request to this resource to retrieve system tracking data for a particular scan
You may filter by datetime:
@ -8,4 +10,4 @@ and module
`?datetime=2015-06-01&module=ansible`
{% include "api/_new_in_awx.md" %}
{% include "api/_new_in_awx.md" %}

View File

@ -1,3 +1,5 @@
# List Fact Scans for a Host by Module and Date
Make a GET request to this resource to retrieve system tracking scans by module and date/time
You may filter scan runs using the `from` and `to` properties:
@ -8,4 +10,4 @@ You may also filter by module
`?module=packages`
{% include "api/_new_in_awx.md" %}
{% include "api/_new_in_awx.md" %}

View File

@ -0,0 +1 @@
# List Red Hat Insights for a Host

View File

@ -1,4 +1,4 @@
# List Root {{ model_verbose_name_plural|title }} for this {{ parent_model_verbose_name|title }}:
# List Root {{ model_verbose_name_plural|title }} for {{ parent_model_verbose_name|title|anora }}:
Make a GET request to this resource to retrieve a list of root (top-level)
{{ model_verbose_name_plural }} associated with this

View File

@ -1,4 +1,4 @@
# Group Tree for this {{ model_verbose_name|title }}:
# Group Tree for {{ model_verbose_name|title|anora }}:
Make a GET request to this resource to retrieve a hierarchical view of groups
associated with the selected {{ model_verbose_name }}.

View File

@ -1,4 +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 }}.
for {{ model_verbose_name|anora }}.

View File

@ -2,7 +2,7 @@
### Note: starting from api v2, this resource object can be accessed via its named URL.
{% endif %}
# Retrieve {{ model_verbose_name|title }}:
# Retrieve {{ model_verbose_name|title|anora }}:
Make GET request to this resource to retrieve a single {{ model_verbose_name }}
record containing the following fields:

View File

@ -2,14 +2,14 @@
### Note: starting from api v2, this resource object can be accessed via its named URL.
{% endif %}
# Retrieve {{ model_verbose_name|title }}:
# Retrieve {{ model_verbose_name|title|anora }}:
Make GET request to this resource to retrieve a single {{ model_verbose_name }}
record containing the following fields:
{% include "api/_result_fields_common.md" %}
# Delete {{ model_verbose_name|title }}:
# Delete {{ model_verbose_name|title|anora }}:
Make a DELETE request to this resource to delete this {{ model_verbose_name }}.

View File

@ -2,14 +2,17 @@
### Note: starting from api v2, this resource object can be accessed via its named URL.
{% endif %}
# Retrieve {{ model_verbose_name|title }}:
{% ifmeth GET %}
# Retrieve {{ model_verbose_name|title|anora }}:
Make GET request to this resource to retrieve a single {{ model_verbose_name }}
record containing the following fields:
{% include "api/_result_fields_common.md" %}
{% endifmeth %}
# Update {{ model_verbose_name|title }}:
{% ifmeth PUT PATCH %}
# Update {{ model_verbose_name|title|anora }}:
Make a PUT or PATCH request to this resource to update this
{{ model_verbose_name }}. The following fields may be modified:
@ -21,5 +24,6 @@ Make a PUT or PATCH request to this resource to update this
For a PUT request, include **all** fields in the request.
For a PATCH request, include only the fields that are being modified.
{% endifmeth %}
{% include "api/_new_in_awx.md" %}

View File

@ -2,14 +2,17 @@
### Note: starting from api v2, this resource object can be accessed via its named URL.
{% endif %}
# Retrieve {{ model_verbose_name|title }}:
{% ifmeth GET %}
# Retrieve {{ model_verbose_name|title|anora }}:
Make GET request to this resource to retrieve a single {{ model_verbose_name }}
record containing the following fields:
{% include "api/_result_fields_common.md" %}
{% endifmeth %}
# Update {{ model_verbose_name|title }}:
{% ifmeth PUT PATCH %}
# Update {{ model_verbose_name|title|anora }}:
Make a PUT or PATCH request to this resource to update this
{{ model_verbose_name }}. The following fields may be modified:
@ -21,9 +24,12 @@ Make a PUT or PATCH request to this resource to update this
For a PUT request, include **all** fields in the request.
For a PATCH request, include only the fields that are being modified.
{% endifmeth %}
# Delete {{ model_verbose_name|title }}:
{% ifmeth DELETE %}
# Delete {{ model_verbose_name|title|anora }}:
Make a DELETE request to this resource to delete this {{ model_verbose_name }}.
{% endifmeth %}
{% include "api/_new_in_awx.md" %}

View File

@ -1,9 +1,11 @@
# List {{ model_verbose_name_plural|title }} for this {{ parent_model_verbose_name|title }}:
{% ifmeth GET %}
# List {{ model_verbose_name_plural|title }} for {{ parent_model_verbose_name|title|anora }}:
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 "api/_list_common.md" %}
{% endifmeth %}
{% include "api/_new_in_awx.md" %}

View File

@ -1,6 +1,6 @@
{% include "api/sub_list_api_view.md" %}
# Create {{ model_verbose_name_plural|title }} for this {{ parent_model_verbose_name|title }}:
# Create {{ model_verbose_name_plural|title }} for {{ parent_model_verbose_name|title|anora }}:
Make a POST request to this resource with the following {{ model_verbose_name }}
fields to create a new {{ model_verbose_name }} associated with this
@ -25,7 +25,7 @@ delete the associated {{ model_verbose_name }}.
}
{% else %}
# Add {{ model_verbose_name_plural|title }} for this {{ parent_model_verbose_name|title }}:
# Add {{ model_verbose_name_plural|title }} for {{ parent_model_verbose_name|title|anora }}:
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 }}.

View File

@ -1,12 +1,16 @@
# List Roles for this Team:
# List Roles for a Team:
{% ifmeth GET %}
Make a GET request to this resource to retrieve a list of roles associated with the selected team.
{% include "api/_list_common.md" %}
{% endifmeth %}
{% ifmeth POST %}
# Associate Roles with this Team:
Make a POST request to this resource to add or remove a role from this team. The following fields may be modified:
* `id`: The Role ID to add to the team. (int, required)
* `disassociate`: Provide if you want to remove the role. (any value, optional)
{% endifmeth %}

View File

@ -1,12 +1,16 @@
# List Roles for this User:
# List Roles for a User:
{% ifmeth GET %}
Make a GET request to this resource to retrieve a list of roles associated with the selected user.
{% include "api/_list_common.md" %}
{% endifmeth %}
{% ifmeth POST %}
# Associate Roles with this User:
Make a POST request to this resource to add or remove a role from this user. The following fields may be modified:
* `id`: The Role ID to add to the user. (int, required)
* `disassociate`: Provide if you want to remove the role. (any value, optional)
{% endifmeth %}

View File

@ -0,0 +1,44 @@
---
# Add categories here for generated Swagger docs; order will be respected
# in the generated document.
categories:
- name: Versioning
- name: Authentication
- name: Instances
- name: Instance Groups
- name: System Configuration
- name: Settings
- name: Dashboard
- name: Organizations
- name: Users
- name: Projects
- name: Project Updates
- name: Teams
- name: Credentials
- name: Credential Types
- name: Inventories
- name: Custom Inventory Scripts
- name: Inventory Sources
- name: Inventory Updates
- name: Groups
- name: Hosts
- name: Job Templates
- name: Jobs
- name: Job Events
- name: Job Host Summaries
- name: Ad Hoc Commands
- name: Ad Hoc Command Events
- name: System Job Templates
- name: System Jobs
- name: Schedules
- name: Roles
- name: Notification Templates
- name: Notifications
- name: Labels
- name: Unified Job Templates
- name: Unified Jobs
- name: Activity Streams
- name: Workflow Job Templates
- name: Workflow Jobs
- name: Workflow Job Template Nodes
- name: Workflow Job Nodes

View File

@ -0,0 +1 @@
The Ansible Tower API Reference Manual provides in-depth documentation for Tower's REST API, including examples on how to integrate with it.

View File

@ -2,7 +2,9 @@
# All Rights Reserved.
from __future__ import absolute_import, unicode_literals
from django.conf import settings
from django.conf.urls import include, url
from awx.api.swagger import SwaggerSchemaView
from awx.api.views import (
ApiRootView,
@ -123,5 +125,9 @@ app_name = 'api'
urlpatterns = [
url(r'^$', ApiRootView.as_view(), name='api_root_view'),
url(r'^(?P<version>(v2))/', include(v2_urls)),
url(r'^(?P<version>(v1|v2))/', include(v1_urls))
url(r'^(?P<version>(v1|v2))/', include(v1_urls)),
]
if settings.SETTINGS_MODULE == 'awx.settings.development':
urlpatterns += [
url(r'^swagger/$', SwaggerSchemaView.as_view(), name='swagger_view'),
]

View File

@ -189,6 +189,7 @@ class ApiRootView(APIView):
permission_classes = (AllowAny,)
view_name = _('REST API')
versioning_class = None
swagger_topic = 'Versioning'
def get(self, request, format=None):
''' list supported API versions '''
@ -210,6 +211,7 @@ class ApiVersionRootView(APIView):
authentication_classes = []
permission_classes = (AllowAny,)
swagger_topic = 'Versioning'
def get(self, request, format=None):
''' list top level resources '''
@ -275,6 +277,7 @@ class ApiV1PingView(APIView):
authentication_classes = ()
view_name = _('Ping')
new_in_210 = True
swagger_topic = 'System Configuration'
def get(self, request, format=None):
"""Return some basic information about this instance.
@ -305,6 +308,7 @@ class ApiV1ConfigView(APIView):
permission_classes = (IsAuthenticated,)
view_name = _('Configuration')
swagger_topic = 'System Configuration'
def check_permissions(self, request):
super(ApiV1ConfigView, self).check_permissions(request)
@ -407,6 +411,7 @@ class DashboardView(APIView):
view_name = _("Dashboard")
new_in_14 = True
swagger_topic = 'Dashboard'
def get(self, request, format=None):
''' Show Dashboard Details '''
@ -506,6 +511,7 @@ class DashboardJobsGraphView(APIView):
view_name = _("Dashboard Jobs Graphs")
new_in_200 = True
swagger_topic = 'Jobs'
def get(self, request, format=None):
period = request.query_params.get('period', 'month')
@ -690,6 +696,8 @@ class SchedulePreview(GenericAPIView):
class ScheduleZoneInfo(APIView):
swagger_topic = 'System Configuration'
def get(self, request):
from dateutil.zoneinfo import get_zonefile_instance
return Response(sorted(get_zonefile_instance().zones.keys()))
@ -750,6 +758,7 @@ class AuthView(APIView):
authentication_classes = []
permission_classes = (AllowAny,)
new_in_240 = True
swagger_topic = 'Authentication'
def get(self, request):
from rest_framework.reverse import reverse
@ -793,6 +802,7 @@ class AuthTokenView(APIView):
permission_classes = (AllowAny,)
serializer_class = AuthTokenSerializer
model = AuthToken
swagger_topic = 'Authentication'
def get_serializer(self, *args, **kwargs):
serializer = self.serializer_class(*args, **kwargs)
@ -982,7 +992,7 @@ class OrganizationDetail(RetrieveUpdateDestroyAPIView):
def get_serializer_context(self, *args, **kwargs):
full_context = super(OrganizationDetail, self).get_serializer_context(*args, **kwargs)
if not hasattr(self, 'kwargs'):
if not hasattr(self, 'kwargs') or 'pk' not in self.kwargs:
return full_context
org_id = int(self.kwargs['pk'])
@ -2680,7 +2690,7 @@ class InventorySourceList(ListCreateAPIView):
@property
def allowed_methods(self):
methods = super(InventorySourceList, self).allowed_methods
if get_request_version(self.request) == 1:
if get_request_version(getattr(self, 'request', None)) == 1:
methods.remove('POST')
return methods
@ -3994,7 +4004,7 @@ class JobList(ListCreateAPIView):
@property
def allowed_methods(self):
methods = super(JobList, self).allowed_methods
if get_request_version(self.request) > 1:
if get_request_version(getattr(self, 'request', None)) > 1:
methods.remove('POST')
return methods

View File

View File

@ -0,0 +1,50 @@
import re
from django.utils.encoding import force_unicode
from django import template
register = template.Library()
CONSONANT_SOUND = re.compile(r'''one(![ir])''', re.IGNORECASE|re.VERBOSE) # noqa
VOWEL_SOUND = re.compile(r'''[aeio]|u([aeiou]|[^n][^aeiou]|ni[^dmnl]|nil[^l])|h(ier|onest|onou?r|ors\b|our(!i))|[fhlmnrsx]\b''', re.IGNORECASE|re.VERBOSE) # noqa
@register.filter
def anora(text):
# https://pypi.python.org/pypi/anora
# < 10 lines of BSD-3 code, not worth a dependency
text = force_unicode(text)
anora = 'an' if not CONSONANT_SOUND.match(text) and VOWEL_SOUND.match(text) else 'a'
return anora + ' ' + text
@register.tag(name='ifmeth')
def ifmeth(parser, token):
"""
Used to mark template blocks for Swagger/OpenAPI output.
If the specified method matches the *current* method in Swagger/OpenAPI
generation, show the block. Otherwise, the block is omitted.
{% ifmeth GET %}
Make a GET request to...
{% endifmeth %}
{% ifmeth PUT PATCH %}
Make a PUT or PATCH request to...
{% endifmeth %}
"""
allowed_methods = [m.upper() for m in token.split_contents()[1:]]
nodelist = parser.parse(('endifmeth',))
parser.delete_first_token()
return MethodFilterNode(allowed_methods, nodelist)
class MethodFilterNode(template.Node):
def __init__(self, allowed_methods, nodelist):
self.allowed_methods = allowed_methods
self.nodelist = nodelist
def render(self, context):
swagger_method = context.get('swagger_method')
if not swagger_method or swagger_method.upper() in self.allowed_methods:
return self.nodelist.render(context)
return ''

View File

View File

@ -0,0 +1,13 @@
from awx.main.tests.functional.conftest import * # noqa
def pytest_addoption(parser):
parser.addoption("--release", action="store", help="a release version number, e.g., 3.3.0")
def pytest_generate_tests(metafunc):
# This is called for every test. Only get/set command line arguments
# if the argument is specified in the list of test "fixturenames".
option_value = metafunc.config.option.release
if 'release' in metafunc.fixturenames and option_value is not None:
metafunc.parametrize("release", [option_value])

View File

@ -0,0 +1,95 @@
import json
import yaml
import os
from coreapi.compat import force_bytes
from django.conf import settings
from openapi_codec.encode import generate_swagger_object
import pytest
import awx
from awx.api.versioning import drf_reverse
config_dest = os.sep.join([
os.path.realpath(os.path.dirname(awx.__file__)),
'api', 'templates', 'swagger'
])
config_file = os.sep.join([config_dest, 'config.yml'])
description_file = os.sep.join([config_dest, 'description.md'])
@pytest.mark.django_db
class TestSwaggerGeneration():
"""
This class is used to generate a Swagger/OpenAPI document for the awx
API. A _prepare fixture generates a JSON blob containing OpenAPI data,
individual tests have the ability modify the payload.
Finally, the JSON content is written to a file, `swagger.json`, in the
current working directory.
$ py.test test_swagger_generation.py --version 3.3.0
To customize the `info.description` in the generated OpenAPI document,
modify the text in `awx.api.templates.swagger.description.md`
"""
JSON = {}
@pytest.fixture(autouse=True, scope='function')
def _prepare(self, get, admin):
if not self.__class__.JSON:
url = drf_reverse('api:swagger_view') + '?format=openapi'
response = get(url, user=admin)
data = generate_swagger_object(response.data)
data.update(response.accepted_renderer.get_customizations() or {})
self.__class__.JSON = data
def _lookup_display_name(self, method, path):
return path
def test_transform_metadata(self, release):
"""
This test takes the JSON output from the swagger endpoint and applies
various transformations to it.
"""
self.__class__.JSON['info']['version'] = release
self.__class__.JSON['host'] = None
self.__class__.JSON['schemes'] = ['https']
self.__class__.JSON['produces'] = ['application/json']
self.__class__.JSON['consumes'] = ['application/json']
# Inject a top-level description into the OpenAPI document
if os.path.exists(description_file):
with open(description_file, 'r') as f:
self.__class__.JSON['info']['description'] = f.read()
# Write tags in the order we want them sorted
if os.path.exists(config_file):
with open(config_file, 'r') as f:
config = yaml.load(f.read())
for category in config.get('categories', []):
tag = {'name': category['name']}
if 'description' in category:
tag['description'] = category['description']
self.__class__.JSON.setdefault('tags', []).append(tag)
revised_paths = {}
for path, node in self.__class__.JSON['paths'].items():
# change {version} in paths to the actual default API version (e.g., v2)
revised_paths[path.replace(
'{version}',
settings.REST_FRAMEWORK['DEFAULT_VERSION']
)] = node
for method in node:
if 'description' in node[method]:
# Pop off the first line and use that as the summary
lines = node[method]['description'].splitlines()
node[method]['summary'] = lines.pop(0).strip('#:')
node[method]['description'] = '\n'.join(lines)
self.__class__.JSON['paths'] = revised_paths
@classmethod
def teardown_class(cls):
with open('swagger.json', 'w') as f:
f.write(force_bytes(json.dumps(cls.JSON)))

View File

@ -1,4 +1,3 @@
# Python
import pytest
import mock

View File

@ -218,6 +218,7 @@ TEMPLATES = [
('django.template.loaders.filesystem.Loader',
'django.template.loaders.app_directories.Loader',),
)],
'builtins': ['awx.main.templatetags.swagger'],
},
'DIRS': [
os.path.join(BASE_DIR, 'templates'),

View File

@ -101,6 +101,8 @@ if 'django_jenkins' in INSTALLED_APPS:
PEP8_RCFILE = "setup.cfg"
PYLINT_RCFILE = ".pylintrc"
INSTALLED_APPS += ('rest_framework_swagger',)
# Much faster than the default
# https://docs.djangoproject.com/en/1.6/topics/auth/passwords/#how-django-stores-passwords
PASSWORD_HASHERS = (

View File

@ -1,4 +1,5 @@
django-debug-toolbar==1.5
django-rest-swagger
pprofile
ipython==5.2.1
unittest2

View File

@ -7,6 +7,7 @@ env:
- AWX_BUILD_TARGET=test
- AWX_BUILD_TARGET=ui-test-ci
- AWX_BUILD_TARGET="flake8 jshint"
- AWX_BUILD_TARGET="swagger"
branches:
only: