awx/awx/main/views.py

937 lines
29 KiB
Python

# Copyright (c) 2013 AnsibleWorks, Inc.
# All Rights Reserved.
# Python
import datetime
import re
import socket
import sys
# 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.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
# AWX
from awx.main.authentication import JobTaskAuthentication
from awx.main.licenses import LicenseReader
from awx.main.base_views import *
from awx.main.models import *
from awx.main.permissions import *
from awx.main.serializers import *
from awx.main.utils import *
def handle_error(request, status=404):
context = {}
#print request.path, status
if request.path.startswith('/admin/'):
template_name = 'admin/%d.html' % status
else:
template_name = '%d.html' % status
return render_to_response(template_name, context,
context_instance=RequestContext(request))
def handle_403(request):
return handle_error(request, 403)
def handle_404(request):
return handle_error(request, 404)
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'
def get(self, request, format=None):
''' list supported API versions '''
current = reverse('main:api_v1_root_view', args=[])
data = dict(
description = 'AWX REST API',
current_version = current,
available_versions = dict(
v1 = current
)
)
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'
def get(self, request, format=None):
''' list top level resources '''
data = dict(
organizations = reverse('main:organization_list'),
users = reverse('main:user_list'),
projects = reverse('main:project_list'),
teams = reverse('main:team_list'),
credentials = reverse('main:credential_list'),
inventory = reverse('main:inventory_list'),
groups = reverse('main:group_list'),
hosts = reverse('main:host_list'),
job_templates = reverse('main:job_template_list'),
jobs = reverse('main:job_list'),
authtoken = reverse('main:auth_token_view'),
me = reverse('main:user_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.
* `license_info`: Information about the current license.
'''
permission_classes = (IsAuthenticated,)
view_name = 'Configuration'
def get(self, request, format=None):
'''Return various sitewide configuration settings.'''
license_reader = LicenseReader()
license_data = license_reader.from_file()
data = dict(
time_zone=settings.TIME_ZONE,
license_info=license_data,
)
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 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
'''
permission_classes = (AllowAny,)
renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES
class OrganizationList(ListCreateAPIView):
model = Organization
serializer_class = OrganizationSerializer
class OrganizationDetail(RetrieveUpdateDestroyAPIView):
model = Organization
serializer_class = OrganizationSerializer
class OrganizationInventoriesList(SubListAPIView):
model = Inventory
serializer_class = InventorySerializer
parent_model = Organization
relationship = 'inventories'
class OrganizationUsersList(SubListCreateAPIView):
model = User
serializer_class = UserSerializer
parent_model = Organization
relationship = 'users'
class OrganizationAdminsList(SubListCreateAPIView):
model = User
serializer_class = UserSerializer
parent_model = Organization
relationship = 'admins'
class OrganizationProjectsList(SubListCreateAPIView):
model = Project
serializer_class = ProjectSerializer
parent_model = Organization
relationship = 'projects'
class OrganizationTeamsList(SubListCreateAPIView):
model = Team
serializer_class = TeamSerializer
parent_model = Organization
relationship = 'teams'
parent_key = 'organization'
class TeamList(ListCreateAPIView):
model = Team
serializer_class = TeamSerializer
class TeamDetail(RetrieveUpdateDestroyAPIView):
model = Team
serializer_class = TeamSerializer
class TeamUsersList(SubListCreateAPIView):
model = User
serializer_class = UserSerializer
parent_model = Team
relationship = 'users'
class TeamPermissionsList(SubListCreateAPIView):
model = Permission
serializer_class = PermissionSerializer
parent_model = Team
relationship = 'permissions'
parent_key = 'team'
def get_queryset(self):
# FIXME: Default get_queryset should handle this.
team = Team.objects.get(pk=self.kwargs['pk'])
base = Permission.objects.filter(team = team)
#if Team.can_user_administrate(self.request.user, team, None):
if self.request.user.can_access(Team, 'change', team, None):
return base
elif team.users.filter(pk=self.request.user.pk).count() > 0:
return base
raise PermissionDenied()
class TeamProjectsList(SubListCreateAPIView):
model = Project
serializer_class = ProjectSerializer
parent_model = Team
relationship = 'projects'
class TeamCredentialsList(SubListCreateAPIView):
model = Credential
serializer_class = CredentialSerializer
parent_model = Team
relationship = 'credentials'
parent_key = 'team'
class ProjectList(ListCreateAPIView):
model = Project
serializer_class = ProjectSerializer
class ProjectDetail(RetrieveUpdateDestroyAPIView):
model = Project
serializer_class = ProjectSerializer
class ProjectDetailPlaybooks(RetrieveAPIView):
model = Project
serializer_class = ProjectPlaybooksSerializer
class ProjectOrganizationsList(SubListCreateAPIView):
model = Organization
serializer_class = OrganizationSerializer
parent_model = Project
relationship = 'organizations'
def get_queryset(self):
# FIXME: Default get_queryset should handle this.
project = Project.objects.get(pk=self.kwargs['pk'])
if not self.request.user.is_superuser:
raise PermissionDenied()
return Organization.objects.filter(projects__in = [ project ])
class ProjectTeamsList(SubListCreateAPIView):
model = Team
serializer_class = TeamSerializer
parent_model = Project
relationship = 'teams'
def get_queryset(self):
project = Project.objects.get(pk=self.kwargs['pk'])
if not self.request.user.is_superuser:
raise PermissionDenied()
return Team.objects.filter(projects__in = [ project ])
class UserList(ListCreateAPIView):
model = User
serializer_class = UserSerializer
class UserMeList(ListAPIView):
model = User
serializer_class = UserSerializer
view_name = 'Me!'
def get_queryset(self):
return self.model.objects.filter(pk=self.request.user.pk)
class UserTeamsList(SubListAPIView):
model = Team
serializer_class = TeamSerializer
parent_model = User
relationship = 'teams'
parent_access = 'read'
class UserPermissionsList(SubListCreateAPIView):
model = Permission
serializer_class = PermissionSerializer
parent_model = User
relationship = 'permissions'
parent_key = 'user'
parent_access = 'read'
class UserProjectsList(SubListAPIView):
model = Project
serializer_class = ProjectSerializer
parent_model = User
relationship = 'projects'
parent_access = 'read'
def get_queryset(self):
parent = self.get_parent_object()
self.check_parent_access(parent)
qs = self.request.user.get_queryset(self.model)
return qs.filter(teams__in=parent.teams.distinct())
class UserCredentialsList(SubListCreateAPIView):
model = Credential
serializer_class = CredentialSerializer
parent_model = User
relationship = 'credentials'
parent_key = 'user'
parent_access = 'read'
class UserOrganizationsList(SubListAPIView):
model = Organization
serializer_class = OrganizationSerializer
parent_model = User
relationship = 'organizations'
parent_access = 'read'
class UserAdminOfOrganizationsList(SubListAPIView):
model = Organization
serializer_class = OrganizationSerializer
parent_model = User
relationship = 'admin_of_organizations'
parent_access = 'read'
class UserDetail(RetrieveUpdateDestroyAPIView):
model = User
serializer_class = UserSerializer
def update_filter(self, request, *args, **kwargs):
''' make sure non-read-only fields that can only be edited by admins, are only edited by admins '''
obj = User.objects.get(pk=kwargs['pk'])
can_change = request.user.can_access(User, 'change', obj, request.DATA)
can_admin = request.user.can_access(User, 'admin', obj, request.DATA)
if can_change and not can_admin:
admin_only_edit_fields = ('last_name', 'first_name', 'username',
'is_active', 'is_superuser')
changed = {}
for field in admin_only_edit_fields:
left = getattr(obj, field, None)
right = request.DATA.get(field, None)
if left is not None and right is not None and left != right:
changed[field] = (left, right)
if changed:
raise PermissionDenied('Cannot change %s' % ', '.join(changed.keys()))
class CredentialList(ListAPIView):
model = Credential
serializer_class = CredentialSerializer
class CredentialDetail(RetrieveUpdateDestroyAPIView):
model = Credential
serializer_class = CredentialSerializer
class PermissionDetail(RetrieveUpdateDestroyAPIView):
model = Permission
serializer_class = PermissionSerializer
class InventoryList(ListCreateAPIView):
model = Inventory
serializer_class = InventorySerializer
class InventoryDetail(RetrieveUpdateDestroyAPIView):
model = Inventory
serializer_class = InventorySerializer
class HostList(ListCreateAPIView):
model = Host
serializer_class = HostSerializer
class HostDetail(RetrieveUpdateDestroyAPIView):
model = Host
serializer_class = HostSerializer
class InventoryHostsList(SubListCreateAPIView):
model = Host
serializer_class = HostSerializer
parent_model = Inventory
relationship = 'hosts'
parent_access = 'read'
parent_key = 'inventory'
class HostGroupsList(SubListCreateAPIView):
''' the list of groups a host is directly a member of '''
model = Group
serializer_class = GroupSerializer
parent_model = Host
relationship = 'groups'
parent_access = 'read'
class HostAllGroupsList(SubListAPIView):
''' the list of all groups of which the host is directly or indirectly a member '''
model = Group
serializer_class = GroupSerializer
parent_model = Host
relationship = 'groups'
parent_access = 'read'
def get_queryset(self):
parent = self.get_parent_object()
self.check_parent_access(parent)
qs = self.request.user.get_queryset(self.model)
sublist_qs = parent.all_groups.distinct()
return qs & sublist_qs
class GroupList(ListCreateAPIView):
model = Group
serializer_class = GroupSerializer
class GroupChildrenList(SubListCreateAPIView):
model = Group
serializer_class = GroupSerializer
parent_model = Group
relationship = 'children'
parent_access = 'read'
class GroupHostsList(SubListCreateAPIView):
''' the list of hosts directly below a group '''
model = Host
serializer_class = HostSerializer
parent_model = Group
relationship = 'hosts'
parent_access = 'read'
class GroupAllHostsList(SubListAPIView):
''' the list of all hosts below a group, even including subgroups '''
model = Host
serializer_class = HostSerializer
parent_model = Group
relationship = 'hosts'
parent_access = 'read'
def get_queryset(self):
parent = self.get_parent_object()
self.check_parent_access(parent)
qs = self.request.user.get_queryset(self.model)
sublist_qs = parent.all_hosts.distinct()
return qs & sublist_qs
class GroupDetail(RetrieveUpdateDestroyAPIView):
model = Group
serializer_class = GroupSerializer
class InventoryGroupsList(SubListCreateAPIView):
model = Group
serializer_class = GroupSerializer
parent_model = Inventory
relationship = 'groups'
parent_access = 'read'
parent_key = 'inventory'
class InventoryRootGroupsList(SubListCreateAPIView):
model = Group
serializer_class = GroupSerializer
parent_model = Inventory
relationship = 'groups'
parent_access = 'read'
parent_key = 'inventory'
def get_queryset(self):
parent = self.get_parent_object()
self.check_parent_access(parent)
qs = self.request.user.get_queryset(self.model)
all_pks = parent.groups.values_list('pk', flat=True)
sublist_qs = parent.groups.exclude(parents__pk__in=all_pks).distinct()
return qs & sublist_qs
class BaseVariableDetail(RetrieveUpdateDestroyAPIView):
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):
model = Inventory
serializer_class = InventoryVariableDataSerializer
class HostVariableDetail(BaseVariableDetail):
model = Host
serializer_class = HostVariableDataSerializer
class GroupVariableDetail(BaseVariableDetail):
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] + \
api_settings.DEFAULT_AUTHENTICATION_CLASSES
permission_classes = (JobTaskPermission,)
filter_backends = ()
def retrieve(self, request, *args, **kwargs):
self.object = self.get_object()
hostname = request.QUERY_PARAMS.get('host', '')
if hostname:
host = get_object_or_404(self.object.hosts, active=True,
name=hostname)
data = host.variables_dict
else:
data = {}
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 = 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,
}
return Response(data)
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"]
}
'''
model = JobTemplate
permission_classes = (JobTemplateCallbackPermission,)
def find_matching_hosts(self):
'''
Find the host(s) in the job template's inventory that match the remote
host for the current request.
'''
# Find the list of remote host names/IPs to check.
remote_hosts = set()
for header in settings.REMOTE_HOST_HEADERS:
value = self.request.META.get(header, '').strip()
if value:
remote_hosts.add(value)
# Add the reverse lookup of IP addresses.
for rh in list(remote_hosts):
try:
result = socket.gethostbyaddr(rh)
except socket.herror:
continue
remote_hosts.add(result[0])
remote_hosts.update(result[1])
# Filter out any .arpa results.
for rh in list(remote_hosts):
if rh.endswith('.arpa'):
remote_hosts.remove(rh)
if not remote_hosts:
return set()
# Find the host objects to search for a match.
obj = self.get_object()
qs = obj.inventory.hosts.filter(active=True)
# First try for an exact match on the name.
try:
return set([qs.get(name__in=remote_hosts)])
except (Host.DoesNotExist, Host.MultipleObjectsReturned):
pass
# Next, try matching based on name or ansible_ssh_host variable.
matches = set()
for host in qs:
ansible_ssh_host = host.variables_dict.get('ansible_ssh_host', '')
if ansible_ssh_host in remote_hosts:
matches.add(host)
# FIXME: Not entirely sure if this statement will ever be needed?
if host.name != ansible_ssh_host and host.name in remote_hosts:
matches.add(host)
if len(matches) == 1:
return matches
# Try to resolve forward addresses for each host to find matches.
for host in qs:
hostnames = set([host.name])
ansible_ssh_host = host.variables_dict.get('ansible_ssh_host', '')
if ansible_ssh_host:
hostnames.add(ansible_ssh_host)
for hostname in hostnames:
try:
result = socket.getaddrinfo(hostname, None)
possible_ips = set(x[4][0] for x in result)
possible_ips.discard(hostname)
if possible_ips and possible_ips & remote_hosts:
matches.add(host)
except socket.gaierror:
pass
# Return all matches found.
return matches
def get(self, request, *args, **kwargs):
job_template = self.get_object()
matching_hosts = self.find_matching_hosts()
data = dict(
host_config_key=job_template.host_config_key,
matching_hosts=[x.name for x in matching_hosts],
)
if settings.DEBUG:
d = dict([(k,v) for k,v in request.META.items()
if k.startswith('HTTP_') or k.startswith('REMOTE_')])
data['request_meta'] = d
return Response(data)
def post(self, request, *args, **kwargs):
job_template = self.get_object()
# Permission class should have already validated host_config_key.
matching_hosts = self.find_matching_hosts()
if not matching_hosts:
data = dict(msg='No matching host could be found!')
# FIXME: Log!
return Response(data, status=status.HTTP_400_BAD_REQUEST)
elif len(matching_hosts) > 1:
data = dict(msg='Multiple hosts matched the request!')
# FIXME: Log!
return Response(data, status=status.HTTP_400_BAD_REQUEST)
else:
host = list(matching_hosts)[0]
if not job_template.can_start_without_user_input():
data = dict(msg='Cannot start automatically, user input required!')
# FIXME: Log!
return Response(data, status=status.HTTP_400_BAD_REQUEST)
limit = ':'.join(filter(None, [job_template.limit, host.name]))
job = job_template.create_job(limit=limit, launch_type='callback')
result = job.start()
if not result:
data = dict(msg='Error starting job!')
return Response(data, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(status=status.HTTP_202_ACCEPTED)
class JobTemplateJobsList(SubListCreateAPIView):
model = Job
serializer_class = JobSerializer
parent_model = JobTemplate
relationship = 'jobs'
parent_key = 'job_template'
class JobList(ListCreateAPIView):
model = Job
serializer_class = JobSerializer
def _get_queryset(self):
return self.model.objects.all() # FIXME
class JobDetail(RetrieveUpdateDestroyAPIView):
model = Job
serializer_class = JobSerializer
def update(self, request, *args, **kwargs):
obj = self.get_object()
# Only allow changes (PUT/PATCH) when job status is "new".
if obj.status != 'new':
return self.http_method_not_allowed(request, *args, **kwargs)
return super(JobDetail, self).update(request, *args, **kwargs)
class JobStart(generics.GenericAPIView):
model = Job
def get(self, request, *args, **kwargs):
obj = self.get_object()
data = dict(
can_start=obj.can_start,
)
if obj.can_start:
data['passwords_needed_to_start'] = obj.get_passwords_needed_to_start()
return Response(data)
def post(self, request, *args, **kwargs):
obj = self.get_object()
if obj.can_start:
result = obj.start(**request.DATA)
if not result:
data = dict(passwords_needed_to_start=obj.get_passwords_needed_to_start())
return Response(data, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(status=status.HTTP_202_ACCEPTED)
else:
return self.http_method_not_allowed(request, *args, **kwargs)
class JobCancel(generics.GenericAPIView):
model = Job
def get(self, request, *args, **kwargs):
obj = self.get_object()
data = dict(
can_cancel=obj.can_cancel,
)
return Response(data)
def post(self, request, *args, **kwargs):
obj = self.get_object()
if obj.can_cancel:
result = obj.cancel()
return Response(status=status.HTTP_202_ACCEPTED)
else:
return self.http_method_not_allowed(request, *args, **kwargs)
class BaseJobHostSummariesList(SubListAPIView):
model = JobHostSummary
serializer_class = JobHostSummarySerializer
parent_model = None # Subclasses must define this attribute.
relationship = 'job_host_summaries'
parent_access = 'read'
view_name = 'Job Host Summary List'
class HostJobHostSummariesList(BaseJobHostSummariesList):
parent_model = Host
class GroupJobHostSummariesList(BaseJobHostSummariesList):
parent_model = Group
class JobJobHostSummariesList(BaseJobHostSummariesList):
parent_model = Job
class JobHostSummaryDetail(RetrieveAPIView):
model = JobHostSummary
serializer_class = JobHostSummarySerializer
class JobEventList(ListAPIView):
model = JobEvent
serializer_class = JobEventSerializer
class JobEventDetail(RetrieveAPIView):
model = JobEvent
serializer_class = JobEventSerializer
class JobEventChildrenList(SubListAPIView):
model = JobEvent
serializer_class = JobEventSerializer
parent_model = JobEvent
relationship = 'children'
view_name = 'Job Event Children List'
class JobEventHostsList(SubListAPIView):
model = Host
serializer_class = HostSerializer
parent_model = JobEvent
relationship = 'hosts'
view_name = 'Job Event Hosts List'
class BaseJobEventsList(SubListAPIView):
model = JobEvent
serializer_class = JobEventSerializer
parent_model = None # Subclasses must define this attribute.
relationship = 'job_events'
parent_access = 'read'
class HostJobEventsList(BaseJobEventsList):
parent_model = Host
class GroupJobEventsList(BaseJobEventsList):
parent_model = Group
class JobJobEventsList(BaseJobEventsList):
parent_model = Job
authentication_classes = [JobTaskAuthentication] + \
api_settings.DEFAULT_AUTHENTICATION_CLASSES
permission_classes = (JobTaskPermission,)
# Post allowed for job event callback only.
def post(self, request, *args, **kwargs):
parent_obj = get_object_or_404(self.parent_model, pk=self.kwargs['pk'])
data = request.DATA.copy()
data['job'] = parent_obj.pk
serializer = self.get_serializer(data=data)
if serializer.is_valid():
self.pre_save(serializer.object)
self.object = serializer.save(force_insert=True)
self.post_save(self.object, created=True)
headers = {'Location': serializer.data['url']}
return Response(serializer.data, status=status.HTTP_201_CREATED,
headers=headers)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# Create view functions for all of the class-based views to simplify inclusion
# in URL patterns and reverse URL lookups, converting CamelCase names to
# lowercase_with_underscore (e.g. MyView.as_view() becomes my_view).
this_module = sys.modules[__name__]
for attr, value in locals().items():
if isinstance(value, type) and issubclass(value, APIView):
name = camelcase_to_underscore(attr)
view = value.as_view()
setattr(this_module, name, view)