diff --git a/awx/api/serializers.py b/awx/api/serializers.py index e9b82569eb..462db339c9 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1597,10 +1597,11 @@ class JobOptionsSerializer(BaseSerializer): fields = ('*', 'job_type', 'inventory', 'project', 'playbook', 'credential', 'cloud_credential', 'forks', 'limit', 'verbosity', 'extra_vars', 'job_tags', 'force_handlers', - 'skip_tags', 'start_at_task') + 'skip_tags', 'start_at_task',) def get_related(self, obj): res = super(JobOptionsSerializer, self).get_related(obj) + res['labels'] = reverse('api:job_template_label_list', args=(obj.pk,)) if obj.inventory: res['inventory'] = reverse('api:inventory_detail', args=(obj.inventory.pk,)) if obj.project: @@ -1662,7 +1663,8 @@ class JobTemplateSerializer(UnifiedJobTemplateSerializer, JobOptionsSerializer): notifiers_success = reverse('api:job_template_notifiers_success_list', args=(obj.pk,)), notifiers_error = reverse('api:job_template_notifiers_error_list', args=(obj.pk,)), access_list = reverse('api:job_template_access_list', args=(obj.pk,)), - survey_spec = reverse('api:job_template_survey_spec', args=(obj.pk,)) + survey_spec = reverse('api:job_template_survey_spec', args=(obj.pk,)), + labels = reverse('api:job_template_label_list', args=(obj.pk,)), )) if obj.host_config_key: res['callback'] = reverse('api:job_template_callback', args=(obj.pk,)) @@ -1716,6 +1718,7 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer): job_host_summaries = reverse('api:job_job_host_summaries_list', args=(obj.pk,)), activity_stream = reverse('api:job_activity_stream_list', args=(obj.pk,)), notifications = reverse('api:job_notifications_list', args=(obj.pk,)), + labels = reverse('api:job_label_list', args=(obj.pk,)), )) if obj.job_template: res['job_template'] = reverse('api:job_template_detail', @@ -2210,6 +2213,19 @@ class NotificationSerializer(BaseSerializer): )) return res + +class LabelSerializer(BaseSerializer): + + class Meta: + model = Label + fields = ('*', '-description', 'organization') + + def get_related(self, obj): + res = super(LabelSerializer, self).get_related(obj) + if obj.organization and obj.organization.active: + res['organization'] = reverse('api:organization_detail', args=(obj.organization.pk,)) + return res + class ScheduleSerializer(BaseSerializer): class Meta: diff --git a/awx/api/urls.py b/awx/api/urls.py index 1b5516a207..f3b24c147a 100644 --- a/awx/api/urls.py +++ b/awx/api/urls.py @@ -181,6 +181,7 @@ job_template_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/notifiers_error/$', 'job_template_notifiers_error_list'), url(r'^(?P[0-9]+)/notifiers_success/$', 'job_template_notifiers_success_list'), url(r'^(?P[0-9]+)/access_list/$', 'job_template_access_list'), + url(r'^(?P[0-9]+)/labels/$', 'job_template_label_list'), ) job_urls = patterns('awx.api.views', @@ -196,6 +197,7 @@ job_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/activity_stream/$', 'job_activity_stream_list'), url(r'^(?P[0-9]+)/stdout/$', 'job_stdout'), url(r'^(?P[0-9]+)/notifications/$', 'job_notifications_list'), + url(r'^(?P[0-9]+)/labels/$', 'job_label_list'), ) job_host_summary_urls = patterns('awx.api.views', @@ -254,6 +256,11 @@ notification_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/$', 'notification_detail'), ) +label_urls = patterns('awx.api.views', + url(r'^$', 'label_list'), + url(r'^(?P[0-9]+)/$', 'label_detail'), +) + schedule_urls = patterns('awx.api.views', url(r'^$', 'schedule_list'), url(r'^(?P[0-9]+)/$', 'schedule_detail'), @@ -303,6 +310,7 @@ v1_urls = patterns('awx.api.views', url(r'^system_jobs/', include(system_job_urls)), url(r'^notifiers/', include(notifier_urls)), url(r'^notifications/', include(notification_urls)), + url(r'^labels/', include(label_urls)), url(r'^unified_job_templates/$','unified_job_template_list'), url(r'^unified_jobs/$', 'unified_job_list'), url(r'^activity_stream/', include(activity_stream_urls)), diff --git a/awx/api/views.py b/awx/api/views.py index f59226196e..8d7332ef4c 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -2161,6 +2161,14 @@ class JobTemplateNotifiersSuccessList(SubListCreateAttachDetachAPIView): parent_model = JobTemplate relationship = 'notifiers_success' +class JobTemplateLabelList(SubListCreateAttachDetachAPIView): + + model = Label + serializer_class = LabelSerializer + parent_model = JobTemplate + relationship = 'labels' + parent_key = 'job_template' + class JobTemplateCallback(GenericAPIView): model = JobTemplate @@ -2425,6 +2433,14 @@ class JobDetail(RetrieveUpdateDestroyAPIView): return self.http_method_not_allowed(request, *args, **kwargs) return super(JobDetail, self).update(request, *args, **kwargs) +class JobLabelList(SubListAPIView): + + model = Label + serializer_class = LabelSerializer + parent_model = Job + relationship = 'labels' + parent_key = 'job' + class JobActivityStreamList(SubListAPIView): model = ActivityStream @@ -3240,6 +3256,18 @@ class NotificationDetail(RetrieveAPIView): serializer_class = NotificationSerializer new_in_300 = True +class LabelList(ListCreateAPIView): + + model = Label + serializer_class = LabelSerializer + new_in_300 = True + +class LabelDetail(RetrieveUpdateAPIView): + + model = Label + serializer_class = LabelSerializer + new_in_300 = True + class ActivityStreamList(SimpleListAPIView): model = ActivityStream diff --git a/awx/main/access.py b/awx/main/access.py index 79f70032df..1d08ccc6eb 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1203,6 +1203,20 @@ class NotificationAccess(BaseAccess): return qs return qs +class LabelAccess(BaseAccess): + ''' + I can see/use a Label if I have permission to + ''' + model = Label + + def get_queryset(self): + qs = self.model.objects.filter(active=True).distinct() + if self.user.is_superuser: + return qs + return qs + + def can_delete(self, obj): + return False class ActivityStreamAccess(BaseAccess): ''' @@ -1417,3 +1431,4 @@ register_access(TowerSettings, TowerSettingsAccess) register_access(Role, RoleAccess) register_access(Notifier, NotifierAccess) register_access(Notification, NotificationAccess) +register_access(Label, LabelAccess) diff --git a/awx/main/migrations/0008_v300_create_labels.py b/awx/main/migrations/0008_v300_create_labels.py new file mode 100644 index 0000000000..0f7dcc79c3 --- /dev/null +++ b/awx/main/migrations/0008_v300_create_labels.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +from django.conf import settings +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('taggit', '0002_auto_20150616_2121'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('main', '0007_v300_credential_domain_field'), + ] + + operations = [ + migrations.CreateModel( + name='Label', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created', models.DateTimeField(default=None, editable=False)), + ('modified', models.DateTimeField(default=None, editable=False)), + ('description', models.TextField(default=b'', blank=True)), + ('active', models.BooleanField(default=True, editable=False)), + ('name', models.CharField(max_length=512)), + ('created_by', models.ForeignKey(related_name="{u'class': 'label', u'app_label': 'main'}(class)s_created+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), + ('modified_by', models.ForeignKey(related_name="{u'class': 'label', u'app_label': 'main'}(class)s_modified+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), + ('organization', models.ForeignKey(related_name='labels', to='main.Organization', help_text='Organization this label belongs to.')), + ('tags', taggit.managers.TaggableManager(to='taggit.Tag', through='taggit.TaggedItem', blank=True, help_text='A comma-separated list of tags.', verbose_name='Tags')), + ], + options={ + 'ordering': ('organization', 'name'), + }, + ), + migrations.AddField( + model_name='activitystream', + name='label', + field=models.ManyToManyField(to='main.Label', blank=True), + ), + migrations.AddField( + model_name='job', + name='labels', + field=models.ManyToManyField(related_name='job_labels', to='main.Label', blank=True), + ), + migrations.AddField( + model_name='jobtemplate', + name='labels', + field=models.ManyToManyField(related_name='jobtemplate_labels', to='main.Label', blank=True), + ), + migrations.AlterUniqueTogether( + name='label', + unique_together=set([('name', 'organization')]), + ), + ] diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index aa5e32224b..d0c62f19b5 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -22,6 +22,7 @@ from awx.main.models.rbac import * # noqa from awx.main.models.mixins import * # noqa from awx.main.models.notifications import * # noqa from awx.main.models.fact import * # noqa +from awx.main.models.label import * # noqa # Monkeypatch Django serializer to ignore django-taggit fields (which break # the dumpdata command; see https://github.com/alex/django-taggit/issues/155). @@ -83,3 +84,4 @@ activity_stream_registrar.connect(CustomInventoryScript) activity_stream_registrar.connect(TowerSettings) activity_stream_registrar.connect(Notifier) activity_stream_registrar.connect(Notification) +activity_stream_registrar.connect(Label) diff --git a/awx/main/models/activity_stream.py b/awx/main/models/activity_stream.py index dfada31484..cffdf83809 100644 --- a/awx/main/models/activity_stream.py +++ b/awx/main/models/activity_stream.py @@ -55,6 +55,7 @@ class ActivityStream(models.Model): custom_inventory_script = models.ManyToManyField("CustomInventoryScript", blank=True) notifier = models.ManyToManyField("Notifier", blank=True) notification = models.ManyToManyField("Notification", blank=True) + label = models.ManyToManyField("Label", blank=True) def get_absolute_url(self): return reverse('api:activity_stream_detail', args=(self.pk,)) diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index eabf1659b3..c14f60963c 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -125,7 +125,11 @@ class JobOptions(BaseModel): become_enabled = models.BooleanField( default=False, ) - + labels = models.ManyToManyField( + "Label", + blank=True, + related_name='%(class)s_labels' + ) extra_vars_dict = VarsDictProperty('extra_vars', True) @@ -210,7 +214,8 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin): return ['name', 'description', 'job_type', 'inventory', 'project', 'playbook', 'credential', 'cloud_credential', 'forks', 'schedule', 'limit', 'verbosity', 'job_tags', 'extra_vars', 'launch_type', - 'force_handlers', 'skip_tags', 'start_at_task', 'become_enabled'] + 'force_handlers', 'skip_tags', 'start_at_task', 'become_enabled', + 'labels',] def create_job(self, **kwargs): ''' diff --git a/awx/main/models/label.py b/awx/main/models/label.py new file mode 100644 index 0000000000..e4b1b1809c --- /dev/null +++ b/awx/main/models/label.py @@ -0,0 +1,33 @@ +# Copyright (c) 2016 Ansible, Inc. +# All Rights Reserved. + +# Django +from django.db import models +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext_lazy as _ + +# AWX +from awx.main.models.base import CommonModelNameNotUnique + +__all__ = ('Label', ) + +class Label(CommonModelNameNotUnique): + ''' + Generic Tag. Designed for tagging Job Templates, but expandable to other models. + ''' + + class Meta: + app_label = 'main' + unique_together = (("name", "organization"),) + ordering = ('organization', 'name') + + organization = models.ForeignKey( + 'Organization', + related_name='labels', + help_text=_('Organization this label belongs to.'), + on_delete=models.CASCADE, + ) + + def get_absolute_url(self): + return reverse('api:label_detail', args=(self.pk,)) + diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 5b9949dbe9..002d04f573 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -299,11 +299,11 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio ''' Create a new unified job based on this unified job template. ''' - save_unified_job = kwargs.pop('save', True) unified_job_class = self._get_unified_job_class() parent_field_name = unified_job_class._get_parent_field_name() kwargs.pop('%s_id' % parent_field_name, None) create_kwargs = {} + m2m_fields = {} create_kwargs[parent_field_name] = self for field_name in self._get_unified_job_field_names(): # Foreign keys can be specified as field_name or field_name_id. @@ -321,14 +321,25 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio elif field_name in kwargs: if field_name == 'extra_vars' and isinstance(kwargs[field_name], dict): create_kwargs[field_name] = json.dumps(kwargs['extra_vars']) + # We can't get a hold of django.db.models.fields.related.ManyRelatedManager to compare + # so this is the next best thing. + elif kwargs[field_name].__class__.__name__ is 'ManyRelatedManager': + m2m_fields[field_name] = kwargs[field_name] else: create_kwargs[field_name] = kwargs[field_name] elif hasattr(self, field_name): - create_kwargs[field_name] = getattr(self, field_name) + field_obj = self._meta.get_field_by_name(field_name)[0] + # Many to Many can be specified as field_name + if isinstance(field_obj, models.ManyToManyField): + m2m_fields[field_name] = getattr(self, field_name) + else: + create_kwargs[field_name] = getattr(self, field_name) new_kwargs = self._update_unified_job_kwargs(**create_kwargs) unified_job = unified_job_class(**new_kwargs) - if save_unified_job: - unified_job.save() + unified_job.save() + for field_name, src_field_value in m2m_fields.iteritems(): + dest_field = getattr(unified_job, field_name) + dest_field.add(*list(src_field_value.all().values_list('id', flat=True))) return unified_job diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index b36d0673e0..04ccd5d528 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -400,3 +400,14 @@ def fact_services_json(): def permission_inv_read(organization, inventory, team): return Permission.objects.create(inventory=inventory, team=team, permission_type=PERM_INVENTORY_READ) + +@pytest.fixture +def job_template_labels(organization): + jt = JobTemplate(name='test-job_template') + jt.save() + + jt.labels.create(name="label-1", organization=organization) + jt.labels.create(name="label-2", organization=organization) + + return jt + diff --git a/awx/main/tests/functional/models/test_unified_job.py b/awx/main/tests/functional/models/test_unified_job.py new file mode 100644 index 0000000000..870f9f034a --- /dev/null +++ b/awx/main/tests/functional/models/test_unified_job.py @@ -0,0 +1,34 @@ +import pytest + + +class TestCreateUnifiedJob: + ''' + Ensure that copying a job template to a job handles many to many field copy + ''' + @pytest.mark.django_db + def test_many_to_many(self, mocker, job_template_labels): + jt = job_template_labels + _get_unified_job_field_names = mocker.patch('awx.main.models.jobs.JobTemplate._get_unified_job_field_names', return_value=['labels']) + j = jt.create_unified_job() + + _get_unified_job_field_names.assert_called_with() + assert j.labels.all().count() == 2 + assert j.labels.all()[0] == jt.labels.all()[0] + assert j.labels.all()[1] == jt.labels.all()[1] + + ''' + Ensure that data is looked for in parameter list before looking at the object + ''' + @pytest.mark.django_db + def test_many_to_many_kwargs(self, mocker, job_template_labels): + jt = job_template_labels + mocked = mocker.MagicMock() + mocked.__class__.__name__ = 'ManyRelatedManager' + kwargs = { + 'labels': mocked + } + _get_unified_job_field_names = mocker.patch('awx.main.models.jobs.JobTemplate._get_unified_job_field_names', return_value=['labels']) + jt.create_unified_job(**kwargs) + + _get_unified_job_field_names.assert_called_with() + mocked.all.assert_called_with()