diff --git a/.gitignore b/.gitignore index b452f7ee24..c385ad667d 100644 --- a/.gitignore +++ b/.gitignore @@ -34,7 +34,7 @@ __pycache__ /tar-build /setup-bundle-build /dist -*.egg-info +/*.egg-info *.py[c,o] # JavaScript diff --git a/Makefile b/Makefile index 39e76bbc4d..00d8f14ad3 100644 --- a/Makefile +++ b/Makefile @@ -273,9 +273,9 @@ version_file: # Do any one-time init tasks. init: @if [ "$(VIRTUAL_ENV)" ]; then \ - $(PYTHON) manage.py register_instance --primary --hostname=127.0.0.1; \ + tower-manage register_instance --primary --hostname=127.0.0.1; \ else \ - sudo $(PYTHON) manage.py register_instance --primary --hostname=127.0.0.1; \ + sudo tower-manage register_instance --primary --hostname=127.0.0.1; \ fi # Refresh development environment after pulling new code. diff --git a/awx/api/serializers.py b/awx/api/serializers.py index b1f11268eb..71a103a1a6 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -558,7 +558,7 @@ class BaseFactSerializer(BaseSerializer): def get_fields(self): ret = super(BaseFactSerializer, self).get_fields() - if 'module' in ret and feature_enabled('system_tracking'): + if 'module' in ret: # TODO: the values_list may pull in a LOT of entries before the distinct is called modules = Fact.objects.all().values_list('module', flat=True).distinct() choices = [(o, o.title()) for o in modules] diff --git a/awx/api/views.py b/awx/api/views.py index 2a9c2b7228..a30ea1870b 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -1289,7 +1289,17 @@ class HostActivityStreamList(SubListAPIView): qs = self.request.user.get_queryset(self.model) return qs.filter(Q(host=parent) | Q(inventory=parent.inventory)) -class HostFactVersionsList(ListAPIView, ParentMixin): +class SystemTrackingEnforcementMixin(APIView): + ''' + Use check_permissions instead of initial() because it's in the OPTION's path as well + ''' + def check_permissions(self, request): + if not feature_enabled("system_tracking"): + raise LicenseForbids("Your license does not permit use " + "of system tracking.") + return super(SystemTrackingEnforcementMixin, self).check_permissions(request) + +class HostFactVersionsList(ListAPIView, ParentMixin, SystemTrackingEnforcementMixin): model = Fact serializer_class = FactVersionSerializer @@ -1297,10 +1307,6 @@ class HostFactVersionsList(ListAPIView, ParentMixin): new_in_220 = True def get_queryset(self): - if not feature_enabled("system_tracking"): - raise LicenseForbids("Your license does not permit use " - "of system tracking.") - from_spec = self.request.query_params.get('from', None) to_spec = self.request.query_params.get('to', None) module_spec = self.request.query_params.get('module', None) @@ -1318,7 +1324,7 @@ class HostFactVersionsList(ListAPIView, ParentMixin): queryset = self.get_queryset() or [] return Response(dict(results=self.serializer_class(queryset, many=True).data)) -class HostFactCompareView(SubDetailAPIView): +class HostFactCompareView(SubDetailAPIView, SystemTrackingEnforcementMixin): model = Fact new_in_220 = True @@ -1326,11 +1332,6 @@ class HostFactCompareView(SubDetailAPIView): serializer_class = FactSerializer def retrieve(self, request, *args, **kwargs): - # Sanity check: Does the license allow system tracking? - if not feature_enabled('system_tracking'): - raise LicenseForbids('Your license does not permit use ' - 'of system tracking.') - datetime_spec = request.query_params.get('datetime', None) module_spec = request.query_params.get('module', "ansible") datetime_actual = dateutil.parser.parse(datetime_spec) if datetime_spec is not None else now() diff --git a/awx/fact/utils/connection.py b/awx/fact/utils/connection.py deleted file mode 100644 index 4c4019e24d..0000000000 --- a/awx/fact/utils/connection.py +++ /dev/null @@ -1,28 +0,0 @@ -# Copyright (c) 2015 Ansible, Inc. -# All Rights Reserved. - -from django.conf import settings -from mongoengine import connect -from mongoengine.connection import ConnectionError -from pymongo.errors import AutoReconnect - -def test_mongo_connection(): - # Connect to Mongo - try: - # Sanity check: If we have intentionally invalid settings, then we - # know we cannot connect. - if settings.MONGO_HOST == NotImplemented: - raise ConnectionError - - # Attempt to connect to the MongoDB database. - db = connect(settings.MONGO_DB, - host=settings.MONGO_HOST, - port=int(settings.MONGO_PORT), - username=settings.MONGO_USERNAME, - password=settings.MONGO_PASSWORD, - tz_aware=settings.USE_TZ) - db[settings.MONGO_DB].command('ping') - return True - except (ConnectionError, AutoReconnect): - return False - diff --git a/awx/main/management/commands/cleanup_facts.py b/awx/main/management/commands/cleanup_facts.py index 11d5d88996..578bee3441 100644 --- a/awx/main/management/commands/cleanup_facts.py +++ b/awx/main/management/commands/cleanup_facts.py @@ -12,7 +12,7 @@ from django.db import transaction from django.utils.timezone import now # AWX -from awx.fact.models.fact import * # noqa +from awx.main.models.fact import Fact from awx.api.license import feature_enabled OLDER_THAN = 'older_than' @@ -31,7 +31,7 @@ class CleanupFacts(object): # pivot -= granularity # group by host def cleanup(self, older_than_abs, granularity, module=None): - fact_oldest = FactVersion.objects.all().order_by('timestamp').first() + fact_oldest = Fact.objects.all().order_by('timestamp').first() if not fact_oldest: return 0 @@ -44,7 +44,10 @@ class CleanupFacts(object): # Special case, granularity=0x where x is d, w, or y # The intent is to delete all facts < older_than_abs if granularity == relativedelta(): - return FactVersion.objects.filter(**kv).order_by('-timestamp').delete() + qs = Fact.objects.filter(**kv) + count = qs.count() + qs.delete() + return count total = 0 @@ -61,18 +64,17 @@ class CleanupFacts(object): kv['module'] = module - fact_version_objs = FactVersion.objects.filter(**kv).order_by('-timestamp').limit(1) - if fact_version_objs: - fact_version_obj = fact_version_objs[0] + fact_version_obj = Fact.objects.filter(**kv).order_by('-timestamp').first() + if fact_version_obj: kv = { 'timestamp__lt': fact_version_obj.timestamp, 'timestamp__gt': date_pivot_next } if module: kv['module'] = module - count = FactVersion.objects.filter(**kv).delete() - # FIXME: These two deletes should be a transaction - count = Fact.objects.filter(**kv).delete() + qs = Fact.objects.filter(**kv) + count = qs.count() + qs.delete() total += count date_pivot = date_pivot_next diff --git a/awx/main/management/commands/run_fact_cache_receiver.py b/awx/main/management/commands/run_fact_cache_receiver.py index 42fc25a561..062cd39693 100644 --- a/awx/main/management/commands/run_fact_cache_receiver.py +++ b/awx/main/management/commands/run_fact_cache_receiver.py @@ -67,7 +67,7 @@ class FactCacheReceiver(object): self.timestamp = datetime.fromtimestamp(date_key, None) # Update existing Fact entry - fact_obj = Fact.get_host_fact(host_obj.id, module_name, self.timestamp) + fact_obj = Fact.objects.filter(host__id=host_obj.id, module=module_name, timestamp=self.timestamp) if fact_obj: fact_obj.facts = facts fact_obj.save() diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 509c5d1e7e..3942cc78bb 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -53,7 +53,6 @@ from awx.main.task_engine import TaskSerializer, TASK_TIMEOUT_INTERVAL from awx.main.utils import (get_ansible_version, get_ssh_version, decrypt_field, update_scm_url, ignore_inventory_computed_fields, emit_websocket_notification, check_proot_installed, build_proot_temp_dir, wrap_args_with_proot) -from awx.fact.utils.connection import test_mongo_connection __all__ = ['RunJob', 'RunSystemJob', 'RunProjectUpdate', 'RunInventoryUpdate', 'RunAdHocCommand', 'handle_work_error', 'handle_work_success', @@ -959,11 +958,6 @@ class RunJob(BaseTask): ''' return getattr(tower_settings, 'AWX_PROOT_ENABLED', False) - def pre_run_hook(self, job, **kwargs): - if job.job_type == PERM_INVENTORY_SCAN: - if not test_mongo_connection(): - raise RuntimeError("Fact Scan Database is offline") - def post_run_hook(self, job, **kwargs): ''' Hook for actions to run after job/task has completed. diff --git a/awx/main/tests/functional/api/test_fact_versions.py b/awx/main/tests/functional/api/test_fact_versions.py index b203c3deff..dfb067a1f8 100644 --- a/awx/main/tests/functional/api/test_fact_versions.py +++ b/awx/main/tests/functional/api/test_fact_versions.py @@ -16,6 +16,9 @@ from django.utils import timezone def mock_feature_enabled(feature, bypass_database=None): return True +def mock_feature_disabled(feature, bypass_database=None): + return False + def setup_common(hosts, fact_scans, get, user, epoch=timezone.now(), get_params={}, host_count=1): hosts = hosts(host_count=host_count) fact_scans(fact_scans=3, timestamp_epoch=epoch) @@ -42,8 +45,33 @@ def check_response_facts(facts_known, response): assert timestamp_apiformat(fact_known.timestamp) == response.data['results'][i]['timestamp'] check_url(response.data['results'][i]['related']['fact_view'], fact_known, fact_known.module) +def check_system_tracking_feature_forbidden(response): + assert 402 == response.status_code + assert 'Your license does not permit use of system tracking.' == response.data['detail'] + +@mock.patch('awx.api.views.feature_enabled', new=mock_feature_disabled) +@pytest.mark.django_db +@pytest.mark.license_feature +def test_system_tracking_license_get(hosts, get, user): + hosts = hosts(host_count=1) + url = reverse('api:host_fact_versions_list', args=(hosts[0].pk,)) + response = get(url, user('admin', True)) + + check_system_tracking_feature_forbidden(response) + +@mock.patch('awx.api.views.feature_enabled', new=mock_feature_disabled) +@pytest.mark.django_db +@pytest.mark.license_feature +def test_system_tracking_license_options(hosts, options, user): + hosts = hosts(host_count=1) + url = reverse('api:host_fact_versions_list', args=(hosts[0].pk,)) + response = options(url, None, user('admin', True)) + + check_system_tracking_feature_forbidden(response) + @mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled) @pytest.mark.django_db +@pytest.mark.license_feature def test_no_facts_db(hosts, get, user): hosts = hosts(host_count=1) url = reverse('api:host_fact_versions_list', args=(hosts[0].pk,)) @@ -72,28 +100,19 @@ def test_basic_fields(hosts, fact_scans, get, user): @mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled) @pytest.mark.django_db -@pytest.mark.skipif(True, reason="Options fix landed in devel but not here. Enable this after this pr gets merged.") +@pytest.mark.license_feature def test_basic_options_fields(hosts, fact_scans, options, user): hosts = hosts(host_count=1) fact_scans(fact_scans=1) url = reverse('api:host_fact_versions_list', args=(hosts[0].pk,)) - response = options(url, user('admin', True), pk=hosts[0].id) + response = options(url, None, user('admin', True), pk=hosts[0].id) - #import json - #print(json.dumps(response.data)) - assert 'related' in response.data - assert 'id' in response.data - assert 'facts' in response.data - assert 'module' in response.data - assert 'host' in response.data - assert isinstance(response.data['host'], int) - assert 'summary_fields' in response.data - assert 'host' in response.data['summary_fields'] - assert 'name' in response.data['summary_fields']['host'] - assert 'description' in response.data['summary_fields']['host'] - assert 'host' in response.data['related'] - assert reverse('api:host_detail', args=(hosts[0].pk,)) == response.data['related']['host'] + assert 'related' in response.data['actions']['GET'] + assert 'module' in response.data['actions']['GET'] + assert ("ansible", "Ansible") in response.data['actions']['GET']['module']['choices'] + assert ("services", "Services") in response.data['actions']['GET']['module']['choices'] + assert ("packages", "Packages") in response.data['actions']['GET']['module']['choices'] @mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled) @pytest.mark.django_db diff --git a/awx/main/tests/functional/api/test_fact_view.py b/awx/main/tests/functional/api/test_fact_view.py index e6cd724d91..ad96d48aee 100644 --- a/awx/main/tests/functional/api/test_fact_view.py +++ b/awx/main/tests/functional/api/test_fact_view.py @@ -1,7 +1,6 @@ import mock import pytest import json -import urllib from awx.main.utils import timestamp_apiformat from django.core.urlresolvers import reverse @@ -10,6 +9,9 @@ from django.utils import timezone def mock_feature_enabled(feature, bypass_database=None): return True +def mock_feature_disabled(feature, bypass_database=None): + return False + # TODO: Consider making the fact_scan() fixture a Class, instead of a function, and move this method into it def find_fact(facts, host_id, module_name, timestamp): for f in facts: @@ -27,6 +29,30 @@ def setup_common(hosts, fact_scans, get, user, epoch=timezone.now(), module_name fact_known = find_fact(facts, hosts[0].id, module_name, epoch) return (fact_known, response) +def check_system_tracking_feature_forbidden(response): + assert 402 == response.status_code + assert 'Your license does not permit use of system tracking.' == response.data['detail'] + +@mock.patch('awx.api.views.feature_enabled', new=mock_feature_disabled) +@pytest.mark.django_db +@pytest.mark.license_feature +def test_system_tracking_license_get(hosts, get, user): + hosts = hosts(host_count=1) + url = reverse('api:host_fact_compare_view', args=(hosts[0].pk,)) + response = get(url, user('admin', True)) + + check_system_tracking_feature_forbidden(response) + +@mock.patch('awx.api.views.feature_enabled', new=mock_feature_disabled) +@pytest.mark.django_db +@pytest.mark.license_feature +def test_system_tracking_license_options(hosts, options, user): + hosts = hosts(host_count=1) + url = reverse('api:host_fact_compare_view', args=(hosts[0].pk,)) + response = options(url, None, user('admin', True)) + + check_system_tracking_feature_forbidden(response) + @mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled) @pytest.mark.django_db def test_no_fact_found(hosts, get, user): diff --git a/awx/main/tests/functional/commands/test_cleanup_facts.py b/awx/main/tests/functional/commands/test_cleanup_facts.py new file mode 100644 index 0000000000..93ddb72d14 --- /dev/null +++ b/awx/main/tests/functional/commands/test_cleanup_facts.py @@ -0,0 +1,200 @@ +# Copyright (c) 2016 Ansible, Inc. +# All Rights Reserved + +# Python +import pytest +import mock +from dateutil.relativedelta import relativedelta +from datetime import timedelta + +# Django +from django.utils import timezone +from django.core.management.base import CommandError + +# AWX +from awx.main.management.commands.cleanup_facts import CleanupFacts, Command +from awx.main.models.fact import Fact +from awx.main.models.inventory import Host + +def mock_feature_enabled(feature, bypass_database=None): + return True + +def mock_feature_disabled(feature, bypass_database=None): + return False + +@pytest.mark.django_db +def test_cleanup_granularity(fact_scans, hosts): + epoch = timezone.now() + hosts(5) + fact_scans(10, timestamp_epoch=epoch) + fact_newest = Fact.objects.all().order_by('-timestamp').first() + timestamp_future = fact_newest.timestamp + timedelta(days=365) + granularity = relativedelta(days=2) + + cleanup_facts = CleanupFacts() + deleted_count = cleanup_facts.cleanup(timestamp_future, granularity) + assert 60 == deleted_count + +''' +Delete half of the scans +''' +@pytest.mark.django_db +def test_cleanup_older_than(fact_scans, hosts): + epoch = timezone.now() + hosts(5) + fact_scans(28, timestamp_epoch=epoch) + qs = Fact.objects.all().order_by('-timestamp') + fact_middle = qs[qs.count() / 2] + granularity = relativedelta() + + cleanup_facts = CleanupFacts() + deleted_count = cleanup_facts.cleanup(fact_middle.timestamp, granularity) + assert 210 == deleted_count + +@pytest.mark.django_db +def test_cleanup_older_than_granularity_module(fact_scans, hosts): + epoch = timezone.now() + hosts(5) + fact_scans(10, timestamp_epoch=epoch) + fact_newest = Fact.objects.all().order_by('-timestamp').first() + timestamp_future = fact_newest.timestamp + timedelta(days=365) + granularity = relativedelta(days=2) + + cleanup_facts = CleanupFacts() + deleted_count = cleanup_facts.cleanup(timestamp_future, granularity, module='ansible') + assert 20 == deleted_count + + +''' +Reduce the granularity of half of the facts scans, by half. +''' +@pytest.mark.django_db +def test_cleanup_logic(fact_scans, hosts): + epoch = timezone.now() + hosts = hosts(5) + fact_scans(60, timestamp_epoch=epoch) + timestamp_middle = epoch + timedelta(days=30) + granularity = relativedelta(days=2) + module = 'ansible' + + cleanup_facts = CleanupFacts() + cleanup_facts.cleanup(timestamp_middle, granularity, module=module) + + + host_ids = Host.objects.all().values_list('id', flat=True) + host_facts = {} + for host_id in host_ids: + facts = Fact.objects.filter(host__id=host_id, module=module, timestamp__lt=timestamp_middle).order_by('-timestamp') + host_facts[host_id] = facts + + for host_id, facts in host_facts.iteritems(): + assert 15 == len(facts) + + timestamp_pivot = timestamp_middle + for fact in facts: + timestamp_pivot -= granularity + assert fact.timestamp == timestamp_pivot + +@mock.patch('awx.main.management.commands.cleanup_facts.feature_enabled', new=mock_feature_disabled) +@pytest.mark.django_db +@pytest.mark.license_feature +def test_system_tracking_feature_disabled(mocker): + cmd = Command() + with pytest.raises(CommandError) as err: + cmd.handle(None) + assert 'The System Tracking feature is not enabled for your Tower instance' in err.value + +@mock.patch('awx.main.management.commands.cleanup_facts.feature_enabled', new=mock_feature_enabled) +@pytest.mark.django_db +def test_parameters_ok(mocker): + run = mocker.patch('awx.main.management.commands.cleanup_facts.CleanupFacts.run') + kv = { + 'older_than': '1d', + 'granularity': '1d', + 'module': None, + } + cmd = Command() + cmd.handle(None, **kv) + run.assert_called_once_with(relativedelta(days=1), relativedelta(days=1), module=None) + +@pytest.mark.django_db +def test_string_time_to_timestamp_ok(): + kvs = [ + { + 'time': '2w', + 'timestamp': relativedelta(weeks=2), + 'msg': '2 weeks', + }, + { + 'time': '23d', + 'timestamp': relativedelta(days=23), + 'msg': '23 days', + }, + { + 'time': '11m', + 'timestamp': relativedelta(months=11), + 'msg': '11 months', + }, + { + 'time': '14y', + 'timestamp': relativedelta(years=14), + 'msg': '14 years', + }, + ] + for kv in kvs: + cmd = Command() + res = cmd.string_time_to_timestamp(kv['time']) + assert kv['timestamp'] == res + +@pytest.mark.django_db +def test_string_time_to_timestamp_invalid(): + kvs = [ + { + 'time': '2weeks', + 'msg': 'weeks instead of w', + }, + { + 'time': '2days', + 'msg': 'days instead of d', + }, + { + 'time': '23', + 'msg': 'no unit specified', + }, + { + 'time': None, + 'msg': 'no value specified', + }, + { + 'time': 'zigzag', + 'msg': 'random string specified', + }, + ] + for kv in kvs: + cmd = Command() + res = cmd.string_time_to_timestamp(kv['time']) + assert res is None + +@mock.patch('awx.main.management.commands.cleanup_facts.feature_enabled', new=mock_feature_enabled) +@pytest.mark.django_db +def test_parameters_fail(mocker): + # Mock run() just in case, but it should never get called because an error should be thrown + mocker.patch('awx.main.management.commands.cleanup_facts.CleanupFacts.run') + kvs = [ + { + 'older_than': '1week', + 'granularity': '1d', + 'msg': '--older_than invalid value "1week"', + }, + { + 'older_than': '1d', + 'granularity': '1year', + 'msg': '--granularity invalid value "1year"', + } + ] + for kv in kvs: + cmd = Command() + with pytest.raises(CommandError) as err: + cmd.handle(None, older_than=kv['older_than'], granularity=kv['granularity']) + assert kv['msg'] in err.value + diff --git a/awx/main/tests/old/commands/cleanup_facts.py b/awx/main/tests/old/commands/cleanup_facts.py deleted file mode 100644 index fc0f049aad..0000000000 --- a/awx/main/tests/old/commands/cleanup_facts.py +++ /dev/null @@ -1,238 +0,0 @@ -# Copyright (c) 2015 Ansible, Inc. -# All Rights Reserved - -# Python -from datetime import datetime -from dateutil.relativedelta import relativedelta -import mock - -#Django -from django.core.management.base import CommandError - -# AWX -from awx.main.tests.base import BaseTest -from awx.fact.tests.base import MongoDBRequired, FactScanBuilder, TEST_FACT_PACKAGES, TEST_FACT_ANSIBLE, TEST_FACT_SERVICES -from command_base import BaseCommandMixin -from awx.main.management.commands.cleanup_facts import Command, CleanupFacts -from awx.fact.models.fact import * # noqa - -__all__ = ['CommandTest','CleanupFactsUnitTest', 'CleanupFactsCommandFunctionalTest'] - -class CleanupFactsCommandFunctionalTest(BaseCommandMixin, BaseTest, MongoDBRequired): - def setUp(self): - super(CleanupFactsCommandFunctionalTest, self).setUp() - self.create_test_license_file() - self.builder = FactScanBuilder() - self.builder.add_fact('ansible', TEST_FACT_ANSIBLE) - - def test_invoke_zero_ok(self): - self.builder.set_epoch(datetime(year=2015, day=2, month=1, microsecond=0)) - self.builder.build(scan_count=20, host_count=10) - - result, stdout, stderr = self.run_command('cleanup_facts', granularity='2y', older_than='1d') - self.assertEqual(stdout, 'Deleted %s facts.\n' % ((200 / 2))) - - def test_invoke_zero_deleted(self): - result, stdout, stderr = self.run_command('cleanup_facts', granularity='1w',older_than='5d') - self.assertEqual(stdout, 'Deleted 0 facts.\n') - - def test_invoke_all_deleted(self): - self.builder.build(scan_count=20, host_count=10) - - result, stdout, stderr = self.run_command('cleanup_facts', granularity='0d', older_than='0d') - self.assertEqual(stdout, 'Deleted 200 facts.\n') - - def test_invoke_params_required(self): - result, stdout, stderr = self.run_command('cleanup_facts') - self.assertIsInstance(result, CommandError) - self.assertEqual(str(result), 'Both --granularity and --older_than are required.') - - def test_module(self): - self.builder.add_fact('packages', TEST_FACT_PACKAGES) - self.builder.add_fact('services', TEST_FACT_SERVICES) - self.builder.build(scan_count=5, host_count=5) - - result, stdout, stderr = self.run_command('cleanup_facts', granularity='0d', older_than='0d', module='packages') - self.assertEqual(stdout, 'Deleted 25 facts.\n') - -class CommandTest(BaseTest): - def setUp(self): - super(CommandTest, self).setUp() - self.create_test_license_file() - - @mock.patch('awx.main.management.commands.cleanup_facts.CleanupFacts.run') - def test_parameters_ok(self, run): - - kv = { - 'older_than': '1d', - 'granularity': '1d', - 'module': None, - } - cmd = Command() - cmd.handle(None, **kv) - run.assert_called_once_with(relativedelta(days=1), relativedelta(days=1), module=None) - - def test_string_time_to_timestamp_ok(self): - kvs = [ - { - 'time': '2w', - 'timestamp': relativedelta(weeks=2), - 'msg': '2 weeks', - }, - { - 'time': '23d', - 'timestamp': relativedelta(days=23), - 'msg': '23 days', - }, - { - 'time': '11m', - 'timestamp': relativedelta(months=11), - 'msg': '11 months', - }, - { - 'time': '14y', - 'timestamp': relativedelta(years=14), - 'msg': '14 years', - }, - ] - for kv in kvs: - cmd = Command() - res = cmd.string_time_to_timestamp(kv['time']) - self.assertEqual(kv['timestamp'], res, "%s should convert to %s" % (kv['time'], kv['msg'])) - - def test_string_time_to_timestamp_invalid(self): - kvs = [ - { - 'time': '2weeks', - 'msg': 'weeks instead of w', - }, - { - 'time': '2days', - 'msg': 'days instead of d', - }, - { - 'time': '23', - 'msg': 'no unit specified', - }, - { - 'time': None, - 'msg': 'no value specified', - }, - { - 'time': 'zigzag', - 'msg': 'random string specified', - }, - ] - for kv in kvs: - cmd = Command() - res = cmd.string_time_to_timestamp(kv['time']) - self.assertIsNone(res, kv['msg']) - - # Mock run() just in case, but it should never get called because an error should be thrown - @mock.patch('awx.main.management.commands.cleanup_facts.CleanupFacts.run') - def test_parameters_fail(self, run): - kvs = [ - { - 'older_than': '1week', - 'granularity': '1d', - 'msg': 'Invalid older_than param value', - }, - { - 'older_than': '1d', - 'granularity': '1year', - 'msg': 'Invalid granularity param value', - } - ] - for kv in kvs: - cmd = Command() - with self.assertRaises(CommandError): - cmd.handle(None, older_than=kv['older_than'], granularity=kv['granularity']) - -class CleanupFactsUnitTest(BaseCommandMixin, BaseTest, MongoDBRequired): - def setUp(self): - super(CleanupFactsUnitTest, self).setUp() - - self.builder = FactScanBuilder() - self.builder.add_fact('ansible', TEST_FACT_ANSIBLE) - self.builder.add_fact('packages', TEST_FACT_PACKAGES) - self.builder.build(scan_count=20, host_count=10) - - ''' - Create 10 hosts with 40 facts each. After cleanup, there should be 20 facts for each host. - Then ensure the correct facts are deleted. - ''' - def test_cleanup_logic(self): - cleanup_facts = CleanupFacts() - fact_oldest = FactVersion.objects.all().order_by('timestamp').first() - granularity = relativedelta(years=2) - - deleted_count = cleanup_facts.cleanup(self.builder.get_timestamp(0), granularity) - self.assertEqual(deleted_count, 2 * (self.builder.get_scan_count() * self.builder.get_host_count()) / 2) - - # Check the number of facts per host - for host in self.builder.get_hosts(): - count = FactVersion.objects.filter(host=host).count() - scan_count = (2 * self.builder.get_scan_count()) / 2 - self.assertEqual(count, scan_count) - - count = Fact.objects.filter(host=host).count() - self.assertEqual(count, scan_count) - - # Ensure that only 2 facts (ansible and packages) exists per granularity time - date_pivot = self.builder.get_timestamp(0) - for host in self.builder.get_hosts(): - while date_pivot > fact_oldest.timestamp: - date_pivot_next = date_pivot - granularity - kv = { - 'timestamp__lte': date_pivot, - 'timestamp__gt': date_pivot_next, - 'host': host, - } - count = FactVersion.objects.filter(**kv).count() - self.assertEqual(count, 2, "should only be 2 FactVersion per the 2 year granularity") - count = Fact.objects.filter(**kv).count() - self.assertEqual(count, 2, "should only be 2 Fact per the 2 year granularity") - date_pivot = date_pivot_next - - ''' - Create 10 hosts with 40 facts each. After cleanup, there should be 30 facts for each host. - Then ensure the correct facts are deleted. - ''' - def test_cleanup_module(self): - cleanup_facts = CleanupFacts() - fact_oldest = FactVersion.objects.all().order_by('timestamp').first() - granularity = relativedelta(years=2) - - deleted_count = cleanup_facts.cleanup(self.builder.get_timestamp(0), granularity, module='ansible') - self.assertEqual(deleted_count, (self.builder.get_scan_count() * self.builder.get_host_count()) / 2) - - # Check the number of facts per host - for host in self.builder.get_hosts(): - count = FactVersion.objects.filter(host=host).count() - self.assertEqual(count, 30) - - count = Fact.objects.filter(host=host).count() - self.assertEqual(count, 30) - - # Ensure that only 1 ansible fact exists per granularity time - date_pivot = self.builder.get_timestamp(0) - for host in self.builder.get_hosts(): - while date_pivot > fact_oldest.timestamp: - date_pivot_next = date_pivot - granularity - kv = { - 'timestamp__lte': date_pivot, - 'timestamp__gt': date_pivot_next, - 'host': host, - 'module': 'ansible', - } - count = FactVersion.objects.filter(**kv).count() - self.assertEqual(count, 1) - count = Fact.objects.filter(**kv).count() - self.assertEqual(count, 1) - date_pivot = date_pivot_next - - - - - - diff --git a/awx/ui/client/assets/cowsay-about.html b/awx/ui/client/assets/cowsay-about.html deleted file mode 100644 index b1cbcf9d24..0000000000 --- a/awx/ui/client/assets/cowsay-about.html +++ /dev/null @@ -1,29 +0,0 @@ - -
-
-
-
-
- ________________
-/  Tower Version \
-\/
- ----------------
-        \   ^__^
-         \  (oo)\_______
-            (__)\     A)\/\
-                ||----w |
-                ||     ||
-
-
-
-
-
-

Copyright 2015. All rights reserved.

-

Ansible and Ansible Tower are registered trademarks of Red Hat, Inc.

-
- -
-

Visit Ansible.com for more information.

-

-
-
diff --git a/awx/ui/client/legacy-styles/ansible-ui.less b/awx/ui/client/legacy-styles/ansible-ui.less index 06d924b141..4adaeb09eb 100644 --- a/awx/ui/client/legacy-styles/ansible-ui.less +++ b/awx/ui/client/legacy-styles/ansible-ui.less @@ -99,7 +99,9 @@ a:focus { color: @blue-dark; text-decoration: none; } - +.btn{ + text-transform: uppercase; +} /* Old style TB default button with grey background */ .btn-grey { color: #333; @@ -917,15 +919,11 @@ input[type="checkbox"].checkbox-no-label { /* Display list actions next to search widget */ .list-actions { - text-align: right; + text-align: right; - button { - margin-left: 4px; - } - - .fa-lg { - vertical-align: -8%; - } + .fa-lg { + vertical-align: -8%; + } } .jqui-accordion { @@ -1950,11 +1948,6 @@ tr td button i { } } -button.dropdown-toggle, -.input-group-btn { - z-index: 1; -} - #login-modal-body { padding-bottom: 5px; } diff --git a/awx/ui/client/legacy-styles/job-details.less b/awx/ui/client/legacy-styles/job-details.less index 621e0267ca..d3cc2bae50 100644 --- a/awx/ui/client/legacy-styles/job-details.less +++ b/awx/ui/client/legacy-styles/job-details.less @@ -166,9 +166,6 @@ .unreachable-hosts-color { color: @unreachable-hosts-color; } - .missing-hosts { - color: transparent; - } .job_well { padding: 8px; @@ -197,9 +194,6 @@ margin-bottom: 0; } - #job-detail-tables { - margin-top: 20px; - } #job_options { height: 100px; @@ -208,7 +202,6 @@ } #job_plays, #job_tasks { - height: 150px; overflow-y: auto; overflow-x: none; } @@ -221,10 +214,7 @@ } #job-detail-container { - position: relative; - padding-left: 15px; - padding-right: 7px; - width: 58.33333333%; + .well { overflow: hidden; } @@ -292,9 +282,6 @@ .row:first-child { border: none; } - .active { - background-color: @active-color; - } .loading-info { padding-top: 5px; padding-left: 3px; @@ -329,10 +316,6 @@ text-overflow: ellipsis; } - #tasks-table-detail { - height: 150px; - } - #play-section { .table-detail { height: 150px; diff --git a/awx/ui/client/legacy-styles/lists.less b/awx/ui/client/legacy-styles/lists.less index 2dcac015c8..ba6adba673 100644 --- a/awx/ui/client/legacy-styles/lists.less +++ b/awx/ui/client/legacy-styles/lists.less @@ -32,6 +32,7 @@ table, tbody { background-color: @list-header-bg; padding-left: 15px; padding-right: 15px; + border-bottom-width:0px!important; } .List-tableHeader:first-of-type { @@ -69,6 +70,7 @@ table, tbody { .List-tableCell { padding-left: 15px; padding-right: 15px; + border-top:0px!important; } .List-actionButtonCell { @@ -141,7 +143,6 @@ table, tbody { .List-header { display: flex; - height: 34px; align-items: center; } @@ -149,7 +150,7 @@ table, tbody { align-items: center; flex: 1 0 auto; display: flex; - margin-top: -2px; + height: 34px; } .List-titleBadge { @@ -170,15 +171,22 @@ table, tbody { text-transform: uppercase; } -.List-actions { +.List-actionHolder { justify-content: flex-end; display: flex; + height: 34px; +} + +.List-actions { margin-top: -10px; +} + +.List-auxAction + .List-actions { margin-left: 10px; } .List-auxAction { - justify-content: flex-end; + align-items: center; display: flex; } @@ -186,6 +194,10 @@ table, tbody { width: 175px; } +.List-action:not(.ng-hide) ~ .List-action:not(.ng-hide) { + margin-left: 10px; +} + .List-buttonSubmit { background-color: @submit-button-bg; color: @submit-button-text; @@ -350,3 +362,25 @@ table, tbody { display: block; font-size: 13px; } + +@media (max-width: 991px) { + .List-searchWidget + .List-searchWidget { + margin-top: 20px; + } +} + +@media (max-width: 600px) { + .List-header { + flex-direction: column; + align-items: stretch; + } + .List-actionHolder { + justify-content: flex-start; + align-items: center; + flex: 1 0 auto; + margin-top: 12px; + } + .List-well { + margin-top: 20px; + } +} diff --git a/awx/ui/client/legacy-styles/main-layout.less b/awx/ui/client/legacy-styles/main-layout.less index 5b7f8f1c01..48d6709168 100644 --- a/awx/ui/client/legacy-styles/main-layout.less +++ b/awx/ui/client/legacy-styles/main-layout.less @@ -60,7 +60,7 @@ body { } #content-container { - margin-top: 40px; + padding-bottom: 40px; } .group-breadcrumbs { diff --git a/awx/ui/client/legacy-styles/stdout.less b/awx/ui/client/legacy-styles/stdout.less index e8f764dee6..61a29dd706 100644 --- a/awx/ui/client/legacy-styles/stdout.less +++ b/awx/ui/client/legacy-styles/stdout.less @@ -32,6 +32,7 @@ #pre-container { overflow-x: scroll; overflow-y: auto; + padding: 10px; } } diff --git a/awx/ui/client/src/about/about.block.less b/awx/ui/client/src/about/about.block.less index 4e46b24b50..d5453c0cf2 100644 --- a/awx/ui/client/src/about/about.block.less +++ b/awx/ui/client/src/about/about.block.less @@ -1,14 +1,42 @@ /** @define About */ -.About { - height: 309px !important; -} +@import "awx/ui/client/src/shared/branding/colors.default.less"; -.About-cowsay { - margin-top: 30px; +.About-cowsay--container{ + width: 340px; + margin: 0 auto; } - -.About-redhat { - max-width: 100%; - margin-top: -61px; - margin-bottom: -33px; +.About-cowsay--code{ + background-color: @default-bg; + padding-left: 30px; + border-style: none; + max-width: 340px; + padding-left: 30px; } +.About .modal-header{ + border: none; + padding-bottom: 0px; +} +.About .modal-dialog{ + max-width: 500px; +} +.About .modal-body{ + padding-top: 0px; +} +.About-brand--redhat{ + max-width: 420px; + margin: 0 auto; + margin-top: -50px; + margin-bottom: -30px; +} +.About-brand--ansible{ + max-width: 120px; + margin: 0 auto; +} +.About-close{ + position: absolute; + top: 15px; + right: 15px; +} +.About p{ + color: @default-interface-txt; +} \ No newline at end of file diff --git a/awx/ui/client/src/about/about.controller.js b/awx/ui/client/src/about/about.controller.js new file mode 100644 index 0000000000..c35388e8ae --- /dev/null +++ b/awx/ui/client/src/about/about.controller.js @@ -0,0 +1,31 @@ +export default + ['$scope', '$state', 'CheckLicense', function($scope, $state, CheckLicense){ + var processVersion = function(version){ + // prettify version & calculate padding + // e,g 3.0.0-0.git201602191743/ -> 3.0.0 + var split = version.split('-')[0] + var spaces = Math.floor((16-split.length)/2), + paddedStr = ""; + for(var i=0; i<=spaces; i++){ + paddedStr = paddedStr +" "; + } + paddedStr = paddedStr + split; + for(var j = paddedStr.length; j<16; j++){ + paddedStr = paddedStr + " "; + } + return paddedStr + } + var init = function(){ + CheckLicense.get() + .then(function(res){ + $scope.subscription = res.data.license_info.subscription_name; + $scope.version = processVersion(res.data.version); + $('#about-modal').modal('show'); + }); + }; + var back = function(){ + $state.go('setup'); + } + init(); + } + ]; \ No newline at end of file diff --git a/awx/ui/client/src/about/about.partial.html b/awx/ui/client/src/about/about.partial.html new file mode 100644 index 0000000000..afc66724f4 --- /dev/null +++ b/awx/ui/client/src/about/about.partial.html @@ -0,0 +1,32 @@ +