diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 2f58b776f4..1487fe17e3 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -918,6 +918,19 @@ class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer): args=(obj.last_update.pk,)) return res + def validate(self, attrs): + organization = None + if 'organization' in attrs: + organization = attrs['organization'] + elif self.instance: + organization = self.instance.organization + + view = self.context.get('view', None) + if not organization and not view.request.user.is_superuser: + # Only allow super users to create orgless projects + raise serializers.ValidationError('Organization is missing') + return super(ProjectSerializer, self).validate(attrs) + class ProjectPlaybooksSerializer(ProjectSerializer): diff --git a/awx/main/migrations/0026_v300_credential_unique.py b/awx/main/migrations/0026_v300_credential_unique.py index b354ce3d62..3c1d714327 100644 --- a/awx/main/migrations/0026_v300_credential_unique.py +++ b/awx/main/migrations/0026_v300_credential_unique.py @@ -20,7 +20,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='credential', name='read_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'singleton:system_auditor', b'use_role', b'admin_role', b'organization.auditor_role'], to='main.Role', null=b'True'), + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'singleton:system_auditor', b'organization.auditor_role', b'use_role', b'admin_role'], to='main.Role', null=b'True'), ), migrations.RunPython(migration_utils.set_current_apps_for_migrations), migrations.RunPython(rbac.rebuild_role_hierarchy), diff --git a/awx/main/migrations/0027_v300_team_migrations.py b/awx/main/migrations/0027_v300_team_migrations.py new file mode 100644 index 0000000000..b53fd8a969 --- /dev/null +++ b/awx/main/migrations/0027_v300_team_migrations.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from awx.main.migrations import _rbac as rbac +from awx.main.migrations import _team_cleanup as team_cleanup +from awx.main.migrations import _migration_utils as migration_utils +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0026_v300_credential_unique'), + ] + + operations = [ + migrations.RunPython(migration_utils.set_current_apps_for_migrations), + migrations.RunPython(team_cleanup.migrate_team), + migrations.RunPython(rbac.rebuild_role_hierarchy), + ] diff --git a/awx/main/migrations/0028_v300_org_team_cascade.py b/awx/main/migrations/0028_v300_org_team_cascade.py new file mode 100644 index 0000000000..80378c5729 --- /dev/null +++ b/awx/main/migrations/0028_v300_org_team_cascade.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import awx.main.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0027_v300_team_migrations'), + ] + + operations = [ + migrations.AlterField( + model_name='team', + name='organization', + field=models.ForeignKey(related_name='teams', to='main.Organization'), + preserve_default=False, + ), + ] diff --git a/awx/main/migrations/_team_cleanup.py b/awx/main/migrations/_team_cleanup.py new file mode 100644 index 0000000000..1a937d1f88 --- /dev/null +++ b/awx/main/migrations/_team_cleanup.py @@ -0,0 +1,30 @@ +# Python +import logging +from django.utils.encoding import smart_text + +logger = logging.getLogger(__name__) + +def log_migration(wrapped): + '''setup the logging mechanism for each migration method + as it runs, Django resets this, so we use a decorator + to re-add the handler for each method. + ''' + handler = logging.FileHandler("/tmp/tower_rbac_migrations.log", mode="a", encoding="UTF-8") + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + handler.setLevel(logging.DEBUG) + handler.setFormatter(formatter) + + def wrapper(*args, **kwargs): + logger.handlers = [] + logger.addHandler(handler) + return wrapped(*args, **kwargs) + return wrapper + +@log_migration +def migrate_team(apps, schema_editor): + '''If an orphan team exists that is still active, delete it.''' + Team = apps.get_model('main', 'Team') + for team in Team.objects.iterator(): + if team.organization is None: + logger.info(smart_text(u"Deleting orphaned team: {}".format(team.name))) + team.delete() diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 3717171411..5f3dc9d7c9 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -92,8 +92,8 @@ class Team(CommonModelNameNotUnique, ResourceMixin): organization = models.ForeignKey( 'Organization', blank=False, - null=True, - on_delete=models.SET_NULL, + null=False, + on_delete=models.CASCADE, related_name='teams', ) deprecated_projects = models.ManyToManyField( diff --git a/awx/main/tests/functional/test_projects.py b/awx/main/tests/functional/test_projects.py index 4c32d1dd69..40ea659432 100644 --- a/awx/main/tests/functional/test_projects.py +++ b/awx/main/tests/functional/test_projects.py @@ -114,3 +114,19 @@ def test_create_project(post, organization, org_admin, org_member, admin, rando, assert result.status_code == expected_status_code if expected_status_code == 201: assert Project.objects.filter(name='Project', organization=organization).exists() + +@pytest.mark.django_db() +def test_create_project_null_organization(post, organization, admin): + post(reverse('api:project_list'), { 'name': 't', 'organization': None}, admin, expect=201) + +@pytest.mark.django_db() +def test_create_project_null_organization_xfail(post, organization, org_admin): + post(reverse('api:project_list'), { 'name': 't', 'organization': None}, org_admin, expect=400) + +@pytest.mark.django_db() +def test_patch_project_null_organization(patch, organization, project, admin): + patch(reverse('api:project_detail', args=(project.id,)), { 'name': 't', 'organization': organization.id}, admin, expect=200) + +@pytest.mark.django_db() +def test_patch_project_null_organization_xfail(patch, project, org_admin): + patch(reverse('api:project_detail', args=(project.id,)), { 'name': 't', 'organization': None}, org_admin, expect=400) diff --git a/awx/settings/local_settings.py.docker_compose b/awx/settings/local_settings.py.docker_compose index f7cf9f0c58..2e27a664ea 100644 --- a/awx/settings/local_settings.py.docker_compose +++ b/awx/settings/local_settings.py.docker_compose @@ -25,6 +25,7 @@ DATABASES = { 'NAME': 'awx-dev', 'USER': 'awx-dev', 'PASSWORD': 'AWXsome1', + 'ATOMIC_REQUESTS': True, 'HOST': 'postgres', 'PORT': '', } diff --git a/awx/ui/client/src/controllers/Jobs.js b/awx/ui/client/src/controllers/Jobs.js index 3a7cfd50f5..7a37106953 100644 --- a/awx/ui/client/src/controllers/Jobs.js +++ b/awx/ui/client/src/controllers/Jobs.js @@ -68,6 +68,7 @@ export function JobsListController ($rootScope, $log, $scope, $compile, $statePa list: AllJobsList, id: 'active-jobs', url: GetBasePath('unified_jobs') + '?status__in=pending,waiting,running,completed,failed,successful,error,canceled,new&order_by=-finished', + pageSize: 20, searchParams: search_params, spinner: false }); @@ -79,6 +80,7 @@ export function JobsListController ($rootScope, $log, $scope, $compile, $statePa parent_scope: $scope, scope: scheduled_scope, list: scheduledJobsList, + pageSize: 20, id: 'scheduled-jobs-tab', searchSize: 'col-lg-4 col-md-4 col-sm-4 col-xs-12', url: scheduledJobsList.basePath diff --git a/awx/ui/client/src/controllers/Projects.js b/awx/ui/client/src/controllers/Projects.js index 64f0bfc8c1..60692d581e 100644 --- a/awx/ui/client/src/controllers/Projects.js +++ b/awx/ui/client/src/controllers/Projects.js @@ -389,6 +389,7 @@ export function ProjectsAdd(Refresh, $scope, $rootScope, $compile, $location, $l master = {}; // remove "type" field from search options + CredentialList = _.cloneDeep(CredentialList); CredentialList.fields.kind.noSearch = true; generator.inject(form, { mode: 'add', related: false, scope: $scope }); @@ -568,6 +569,7 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log, relatedSets = {}; // remove "type" field from search options + CredentialList = _.cloneDeep(CredentialList); CredentialList.fields.kind.noSearch = true; diff --git a/awx/ui/client/src/helpers/JobDetail.js b/awx/ui/client/src/helpers/JobDetail.js index dde9264109..3b7dcca1ce 100644 --- a/awx/ui/client/src/helpers/JobDetail.js +++ b/awx/ui/client/src/helpers/JobDetail.js @@ -687,7 +687,7 @@ export default scope.plays = []; url = scope.job.url + 'job_plays/?page_size=' + scope.playsMaxRows + '&order=id'; - url += (scope.search_play_name) ? '&play__icontains=' + scope.search_play_name : ''; + url += (scope.search_play_name) ? '&play__icontains=' + encodeURIComponent(scope.search_play_name) : ''; url += (scope.search_play_status === 'failed') ? '&failed=true' : ''; scope.playsLoading = true; Rest.setUrl(url); @@ -786,7 +786,7 @@ export default scope.tasks = []; if (scope.selectedPlay) { url = scope.job.url + 'job_tasks/?event_id=' + scope.selectedPlay; - url += (scope.search_task_name) ? '&task__icontains=' + scope.search_task_name : ''; + url += (scope.search_task_name) ? '&task__icontains=' + encodeURIComponent(scope.search_task_name) : ''; url += (scope.search_task_status === 'failed') ? '&failed=true' : ''; url += '&page_size=' + scope.tasksMaxRows + '&order=id'; scope.plays.every(function(p, idx) { diff --git a/awx/ui/client/src/helpers/JobTemplates.js b/awx/ui/client/src/helpers/JobTemplates.js index 55b2419390..2947c05546 100644 --- a/awx/ui/client/src/helpers/JobTemplates.js +++ b/awx/ui/client/src/helpers/JobTemplates.js @@ -36,6 +36,8 @@ angular.module('JobTemplatesHelper', ['Utilities']) // checkSCMStatus, getPlaybooks, callback, // choicesCount = 0; + CredentialList = _.cloneDeep(CredentialList); + // The form uses awPopOverWatch directive to 'watch' scope.callback_help for changes. Each time the // popover is activated, a function checks the value of scope.callback_help before constructing the content. scope.setCallbackHelp = function() { diff --git a/awx/ui/client/src/inventories/manage/groups/groups-add.controller.js b/awx/ui/client/src/inventories/manage/groups/groups-add.controller.js index a405f2bb52..a816cacd3a 100644 --- a/awx/ui/client/src/inventories/manage/groups/groups-add.controller.js +++ b/awx/ui/client/src/inventories/manage/groups/groups-add.controller.js @@ -13,6 +13,7 @@ form = GroupForm(); // remove "type" field from search options + CredentialList = _.cloneDeep(CredentialList); CredentialList.fields.kind.noSearch = true; $scope.formCancel = function(){ diff --git a/awx/ui/client/src/inventories/manage/groups/groups-edit.controller.js b/awx/ui/client/src/inventories/manage/groups/groups-edit.controller.js index 094b53399a..b008c0f888 100644 --- a/awx/ui/client/src/inventories/manage/groups/groups-edit.controller.js +++ b/awx/ui/client/src/inventories/manage/groups/groups-edit.controller.js @@ -15,6 +15,7 @@ form = GroupForm(); // remove "type" field from search options + CredentialList = _.cloneDeep(CredentialList); CredentialList.fields.kind.noSearch = true; $scope.formCancel = function(){ diff --git a/awx/ui/client/src/job-detail/host-events/host-events.controller.js b/awx/ui/client/src/job-detail/host-events/host-events.controller.js index 55abf51e04..f56111bdb6 100644 --- a/awx/ui/client/src/job-detail/host-events/host-events.controller.js +++ b/awx/ui/client/src/job-detail/host-events/host-events.controller.js @@ -25,8 +25,8 @@ host_name: $scope.hostName, }; if ($scope.searchStr && $scope.searchStr !== ''){ - params.or__play__icontains = $scope.searchStr; - params.or__task__icontains = $scope.searchStr; + params.or__play__icontains = encodeURIComponent($scope.searchStr); + params.or__task__icontains = encodeURIComponent($scope.searchStr); } switch($scope.activeFilter){ diff --git a/awx/ui/client/src/job-templates/edit/job-templates-edit.controller.js b/awx/ui/client/src/job-templates/edit/job-templates-edit.controller.js index c893463c28..8a2f433dbc 100644 --- a/awx/ui/client/src/job-templates/edit/job-templates-edit.controller.js +++ b/awx/ui/client/src/job-templates/edit/job-templates-edit.controller.js @@ -47,6 +47,7 @@ export default choicesCount = 0; // remove "type" field from search options + CredentialList = _.cloneDeep(CredentialList); CredentialList.fields.kind.noSearch = true; CallbackHelpInit({ scope: $scope }); @@ -471,7 +472,7 @@ export default }); } else { - // job template doesn't exist + // job template doesn't exist $scope.$emit("choicesReady"); } diff --git a/awx/ui/client/src/scheduler/schedulerEdit.controller.js b/awx/ui/client/src/scheduler/schedulerEdit.controller.js index 36e94151d3..2687f67ecf 100644 --- a/awx/ui/client/src/scheduler/schedulerEdit.controller.js +++ b/awx/ui/client/src/scheduler/schedulerEdit.controller.js @@ -1,4 +1,20 @@ -export default ['$compile', '$state', '$stateParams', 'EditSchedule', 'Wait', '$scope', '$rootScope', 'CreateSelect2', 'ParseTypeChange', function($compile, $state, $stateParams, EditSchedule, Wait, $scope, $rootScope, CreateSelect2, ParseTypeChange) { +export default ['$filter', '$compile', '$state', '$stateParams', 'EditSchedule', 'Wait', '$scope', '$rootScope', 'CreateSelect2', 'ParseTypeChange', +function($filter, $compile, $state, $stateParams, EditSchedule, Wait, $scope, $rootScope, CreateSelect2, ParseTypeChange) { + + $scope.processSchedulerEndDt = function(){ + // set the schedulerEndDt to be equal to schedulerStartDt + 1 day @ midnight + var dt = new Date($scope.schedulerUTCTime); + // increment date by 1 day + dt.setDate(dt.getDate() + 1); + var month = $filter('schZeroPad')(dt.getMonth() + 1, 2), + day = $filter('schZeroPad')(dt.getDate(), 2); + $scope.$parent.schedulerEndDt = month + '/' + day + '/' + dt.getFullYear(); + }; + // initial end @ midnight values + $scope.schedulerEndHour = "00"; + $scope.schedulerEndMinute = "00"; + $scope.schedulerEndSecond = "00"; + $scope.$on("ScheduleFormCreated", function(e, scope) { $scope.hideForm = false; $scope = angular.extend($scope, scope); diff --git a/awx/ui/client/src/search/tagSearch.service.js b/awx/ui/client/src/search/tagSearch.service.js index fdd808d3ad..4e5e6ae3ec 100644 --- a/awx/ui/client/src/search/tagSearch.service.js +++ b/awx/ui/client/src/search/tagSearch.service.js @@ -85,7 +85,7 @@ export default ['Rest', '$q', 'GetBasePath', 'Wait', 'ProcessErrors', '$log', fu if (needsRequest.length) { // make the options request to reutrn the typeOptions var url = needsRequest[0].basePath ? GetBasePath(needsRequest[0].basePath) : basePath; - if(url.indexOf('null') === 0 ){ + if(url.indexOf('null') === -1 ){ Rest.setUrl(url); Rest.options() .success(function (data) { diff --git a/docs/licenses/cowsay.txt b/docs/licenses/cowsay.txt new file mode 100644 index 0000000000..a58a648c73 --- /dev/null +++ b/docs/licenses/cowsay.txt @@ -0,0 +1,26 @@ +cowsay is licensed under the following MIT license: + +==== +Copyright (c) 2012 Fabio Crisci + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==== + +The original idea of cowsay come from [Tony Monroe](http://www.nog.net/~tony/) - [cowsay](https://github.com/schacon/cowsay) diff --git a/docs/licenses/font-awesome.txt b/docs/licenses/font-awesome.txt new file mode 100644 index 0000000000..0928d89347 --- /dev/null +++ b/docs/licenses/font-awesome.txt @@ -0,0 +1,13 @@ +Font License + +Applies to all desktop and webfont files in the following directory: font-awesome/fonts/. +License: SIL OFL 1.1 +URL: http://scripts.sil.org/OFL + + + +Code License + +Applies to all CSS and LESS files in the following directories: font-awesome/css/, font-awesome/less/, and font-awesome/scss/. +License: MIT License +URL: http://opensource.org/licenses/mit-license.html