diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 95d507e296..2c0fc96d43 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -93,6 +93,7 @@ class ChoiceField(fields.ChoiceField): # ModelSerializer. serializers.ChoiceField = ChoiceField + class BaseSerializer(serializers.ModelSerializer): # add the URL and related resources @@ -184,10 +185,6 @@ class BaseSerializer(serializers.ModelSerializer): else: return obj.active - def validate_description(self, attrs, source): - # Description should always be empty string, never null. - attrs[source] = attrs.get(source, None) or '' - return attrs class UserSerializer(BaseSerializer): @@ -275,6 +272,7 @@ class UserSerializer(BaseSerializer): def validate_is_superuser(self, attrs, source): return self._validate_ldap_managed_field(attrs, source) + class OrganizationSerializer(BaseSerializer): class Meta: @@ -296,6 +294,7 @@ class OrganizationSerializer(BaseSerializer): )) return res + class ProjectSerializer(BaseSerializer): playbooks = serializers.Field(source='playbooks', help_text='Array of playbooks available within this project.') @@ -415,6 +414,7 @@ class ProjectSerializer(BaseSerializer): # FIXME: Validate combination of SCM URL and credential! + class ProjectPlaybooksSerializer(ProjectSerializer): class Meta: @@ -425,6 +425,7 @@ class ProjectPlaybooksSerializer(ProjectSerializer): ret = super(ProjectPlaybooksSerializer, self).to_native(obj) return ret.get('playbooks', []) + class ProjectUpdateSerializer(BaseSerializer): class Meta: @@ -443,6 +444,7 @@ class ProjectUpdateSerializer(BaseSerializer): )) return res + class BaseSerializerWithVariables(BaseSerializer): def validate_variables(self, attrs, source): @@ -455,6 +457,7 @@ class BaseSerializerWithVariables(BaseSerializer): raise serializers.ValidationError('Must be valid JSON or YAML') return attrs + class InventorySerializer(BaseSerializerWithVariables): class Meta: @@ -483,6 +486,7 @@ class InventorySerializer(BaseSerializerWithVariables): )) return res + class HostSerializer(BaseSerializerWithVariables): class Meta: @@ -613,6 +617,7 @@ class GroupSerializer(BaseSerializerWithVariables): raise serializers.ValidationError('Invalid group name') return attrs + class GroupTreeSerializer(GroupSerializer): children = serializers.SerializerMethodField('get_children') @@ -630,6 +635,7 @@ class GroupTreeSerializer(GroupSerializer): children_qs = obj.children.filter(active=True) return GroupTreeSerializer(children_qs, many=True).data + class BaseVariableDataSerializer(BaseSerializer): def to_native(self, obj): @@ -645,24 +651,28 @@ class BaseVariableDataSerializer(BaseSerializer): data = {'variables': json.dumps(data)} return super(BaseVariableDataSerializer, self).from_native(data, files) + class InventoryVariableDataSerializer(BaseVariableDataSerializer): class Meta: model = Inventory fields = ('variables',) + class HostVariableDataSerializer(BaseVariableDataSerializer): class Meta: model = Host fields = ('variables',) + class GroupVariableDataSerializer(BaseVariableDataSerializer): class Meta: model = Group fields = ('variables',) + class InventorySourceSerializer(BaseSerializer): #source_password = serializers.WritableField(required=False, default='') @@ -732,6 +742,7 @@ class InventorySourceSerializer(BaseSerializer): # FIXME return attrs + class InventoryUpdateSerializer(BaseSerializer): class Meta: @@ -751,6 +762,7 @@ class InventoryUpdateSerializer(BaseSerializer): )) return res + class TeamSerializer(BaseSerializer): class Meta: @@ -770,6 +782,7 @@ class TeamSerializer(BaseSerializer): )) return res + class PermissionSerializer(BaseSerializer): class Meta: @@ -791,6 +804,7 @@ class PermissionSerializer(BaseSerializer): res['inventory'] = reverse('api:inventory_detail', args=(obj.inventory.pk,)) return res + def validate(self, attrs): # Can only set either user or team. if attrs['user'] and attrs['team']: @@ -806,6 +820,7 @@ class PermissionSerializer(BaseSerializer): 'assigning deployment permissions') return attrs + class CredentialSerializer(BaseSerializer): # FIXME: may want to make some of these filtered based on user accessing @@ -847,6 +862,7 @@ class CredentialSerializer(BaseSerializer): res['team'] = reverse('api:team_detail', args=(obj.team.pk,)) return res + class JobTemplateSerializer(BaseSerializer): class Meta: @@ -881,6 +897,7 @@ class JobTemplateSerializer(BaseSerializer): raise serializers.ValidationError('Playbook not found for project') return attrs + class JobSerializer(BaseSerializer): passwords_needed_to_start = serializers.Field(source='passwords_needed_to_start') @@ -943,6 +960,7 @@ class JobSerializer(BaseSerializer): data.setdefault('job_tags', job_template.job_tags) return super(JobSerializer, self).from_native(data, files) + class JobHostSummarySerializer(BaseSerializer): class Meta: @@ -972,6 +990,7 @@ class JobHostSummarySerializer(BaseSerializer): pass return d + class JobEventSerializer(BaseSerializer): event_display = serializers.Field(source='get_event_display2') @@ -1013,6 +1032,7 @@ class JobEventSerializer(BaseSerializer): pass return d + class AuthTokenSerializer(serializers.Serializer): username = serializers.CharField() diff --git a/awx/main/models/base.py b/awx/main/models/base.py index 64c7006911..8304144f50 100644 --- a/awx/main/models/base.py +++ b/awx/main/models/base.py @@ -10,6 +10,7 @@ import yaml # Django from django.db import models +from django.core.exceptions import ValidationError from django.utils.translation import ugettext_lazy as _ from django.utils.timezone import now @@ -22,7 +23,7 @@ from taggit.managers import TaggableManager # Django-Celery from djcelery.models import TaskMeta -__all__ = ['VarsDictProperty', 'PrimordialModel', 'CommonModel', +__all__ = ['VarsDictProperty', 'BaseModel', 'PrimordialModel', 'CommonModel', 'CommonModelNameNotUnique', 'CommonTask', 'PERM_INVENTORY_ADMIN', 'PERM_INVENTORY_READ', 'PERM_INVENTORY_WRITE', 'PERM_INVENTORY_DEPLOY', 'PERM_INVENTORY_CHECK', 'JOB_TYPE_CHOICES', @@ -97,7 +98,56 @@ class VarsDictProperty(object): raise AttributeError('readonly property') -class PrimordialModel(models.Model): +class BaseModel(models.Model): + ''' + Base model class with common methods for all models. + ''' + + class Meta: + abstract = True + + def __unicode__(self): + if hasattr(self, 'name'): + return u'%s-%s' % (self.name, self.id) + else: + return u'%s-%s' % (self._meta.verbose_name, self.id) + + def clean_fields(self, exclude=None): + ''' + Override default clean_fields to support methods for cleaning + individual model fields. + ''' + exclude = exclude or [] + errors = {} + try: + super(BaseModel, self).clean_fields(exclude) + except ValidationError, e: + errors = e.update_error_dict(errors) + for f in self._meta.fields: + if f.name in exclude: + continue + if hasattr(self, 'clean_%s' % f.name): + try: + setattr(self, f.attname, + getattr(self, 'clean_%s' % f.name)()) + except ValidationError, e: + errors[f.name] = e.messages + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + # For compatibility with Django 1.4.x, attempt to handle any calls to + # save that pass update_fields. + try: + super(BaseModel, self).save(*args, **kwargs) + except TypeError: + if 'update_fields' not in kwargs: + raise + kwargs.pop('update_fields') + super(BaseModel, self).save(*args, **kwargs) + + +class PrimordialModel(BaseModel): ''' common model for all object types that have these standard fields must use a subclass CommonModel or CommonModelNameNotUnique though @@ -136,27 +186,11 @@ class PrimordialModel(models.Model): ) active = models.BooleanField( default=True, + editable=False, ) tags = TaggableManager(blank=True) - def __unicode__(self): - if hasattr(self, 'name'): - return u'%s-%s' % (self.name, self.id) - else: - return u'%s-%s' % (self._meta.verbose_name, self.id) - - def save(self, *args, **kwargs): - # For compatibility with Django 1.4.x, attempt to handle any calls to - # save that pass update_fields. - try: - super(PrimordialModel, self).save(*args, **kwargs) - except TypeError: - if 'update_fields' not in kwargs: - raise - kwargs.pop('update_fields') - super(PrimordialModel, self).save(*args, **kwargs) - def mark_inactive(self, save=True): '''Use instead of delete to rename and mark inactive.''' if self.active: @@ -166,6 +200,10 @@ class PrimordialModel(models.Model): if save: self.save() + def clean_description(self): + # Description should always be empty string, never null. + return self.description or '' + class CommonModel(PrimordialModel): ''' a base model where the name is unique ''' diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index a5c5517b27..539660bf85 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -303,7 +303,7 @@ class Job(CommonTask): return self._get_hosts(job_host_summaries__processed__gt=0) -class JobHostSummary(models.Model): +class JobHostSummary(BaseModel): ''' Per-host statistics for each job. ''' @@ -367,7 +367,7 @@ class JobHostSummary(models.Model): self.host.save(update_fields=update_fields) self.host.update_computed_fields() -class JobEvent(models.Model): +class JobEvent(BaseModel): ''' An event/message logged from the callback when running a job. ''' diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 6d80c080fc..903474401f 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -254,6 +254,16 @@ class Credential(CommonModelNameNotUnique): def get_absolute_url(self): return reverse('api:credential_detail', args=(self.pk,)) + def clean_ssh_key_unlock(self): + if self.pk: + ssh_key_data = decrypt_field(self, 'ssh_key_data') + else: + ssh_key_data = self.ssh_key_data + if 'ENCRYPTED' in ssh_key_data and not self.ssh_key_unlock: + raise ValidationError('SSH key unlock must be set when SSH key ' + 'data is encrypted') + return self.ssh_key_unlock + def clean(self): if self.user and self.team: raise ValidationError('Credential cannot be assigned to both a user and team') @@ -343,7 +353,7 @@ class Credential(CommonModelNameNotUnique): update_fields.append(field) self.save(update_fields=update_fields) -class Profile(models.Model): +class Profile(BaseModel): ''' Profile model related to User object. Currently stores LDAP DN for users loaded from LDAP. @@ -368,7 +378,7 @@ class Profile(models.Model): default='', ) -class AuthToken(models.Model): +class AuthToken(BaseModel): ''' Custom authentication tokens per user with expiration and request-specific data. diff --git a/awx/main/tests/projects.py b/awx/main/tests/projects.py index b70f43ece7..4facb39d5a 100644 --- a/awx/main/tests/projects.py +++ b/awx/main/tests/projects.py @@ -509,7 +509,13 @@ class ProjectsTest(BaseTest): data = dict(name='zyx', user=self.super_django_user.pk, kind='ssh', sudo_username=None) self.post(url, data, expect=400) - + + # Test with encrypted ssh key and no unlock password. + with self.current_user(self.super_django_user): + data = dict(name='zyx', user=self.super_django_user.pk, kind='ssh', + ssh_key_data=TEST_SSH_KEY_DATA_LOCKED) + self.post(url, data, expect=400) + # FIXME: Check list as other users. # can edit a credential