diff --git a/pdc/apps/partners/__init__.py b/pdc/apps/partners/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pdc/apps/partners/admin.py b/pdc/apps/partners/admin.py new file mode 100644 index 00000000..9b907d50 --- /dev/null +++ b/pdc/apps/partners/admin.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2015 Red Hat +# Licensed under The MIT License (MIT) +# http://opensource.org/licenses/MIT +# +from pdc.apps.common.register_to_admin import register + +register('partners') diff --git a/pdc/apps/partners/filters.py b/pdc/apps/partners/filters.py new file mode 100644 index 00000000..8a9a9405 --- /dev/null +++ b/pdc/apps/partners/filters.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2015 Red Hat +# Licensed under The MIT License (MIT) +# http://opensource.org/licenses/MIT +# +import django_filters + +from pdc.apps.common import filters +from . import models + + +class PartnerFilterSet(django_filters.FilterSet): + enabled = filters.CaseInsensitiveBooleanFilter() + binary = filters.CaseInsensitiveBooleanFilter() + source = filters.CaseInsensitiveBooleanFilter() + + class Meta: + model = models.Partner + fields = ('short', 'enabled', 'binary', 'source', 'ftp_dir', 'rsync_dir') + + +class PartnerMappingFilterSet(django_filters.FilterSet): + partner = filters.MultiValueFilter(name='partner__short') + release = filters.MultiValueFilter(name='variant_arch__variant__release__release_id') + variant = filters.MultiValueFilter(name='variant_arch__variant__variant_uid') + arch = filters.MultiValueFilter(name='variant_arch__arch__name') + + class Meta: + model = models.PartnerMapping + fields = ('partner', 'release', 'variant', 'arch') diff --git a/pdc/apps/partners/migrations/0001_initial.py b/pdc/apps/partners/migrations/0001_initial.py new file mode 100644 index 00000000..19096a22 --- /dev/null +++ b/pdc/apps/partners/migrations/0001_initial.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2015 Red Hat +# Licensed under The MIT License (MIT) +# http://opensource.org/licenses/MIT +# +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('release', '0002_auto_20150512_0719'), + ] + + operations = [ + migrations.CreateModel( + name='Partner', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('short', models.CharField(unique=True, max_length=100)), + ('name', models.CharField(max_length=250)), + ('binary', models.BooleanField(default=True)), + ('source', models.BooleanField(default=True)), + ('enabled', models.BooleanField(default=True)), + ('ftp_dir', models.CharField(max_length=500)), + ('rsync_dir', models.CharField(max_length=500)), + ], + ), + migrations.CreateModel( + name='PartnerMapping', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('partner', models.ForeignKey(to='partners.Partner')), + ('variant_arch', models.ForeignKey(to='release.VariantArch')), + ], + ), + migrations.CreateModel( + name='PartnerType', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('name', models.CharField(unique=True, max_length=100)), + ], + ), + migrations.AddField( + model_name='partner', + name='type', + field=models.ForeignKey(to='partners.PartnerType'), + ), + migrations.AlterUniqueTogether( + name='partnermapping', + unique_together=set([('partner', 'variant_arch')]), + ), + ] diff --git a/pdc/apps/partners/migrations/__init__.py b/pdc/apps/partners/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pdc/apps/partners/models.py b/pdc/apps/partners/models.py new file mode 100644 index 00000000..9f0c89f4 --- /dev/null +++ b/pdc/apps/partners/models.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2015 Red Hat +# Licensed under The MIT License (MIT) +# http://opensource.org/licenses/MIT +# +from django.db import models + + +class PartnerType(models.Model): + name = models.CharField(unique=True, max_length=100) + + def __unicode__(self): + return unicode(self.name) + + +class Partner(models.Model): + short = models.CharField(unique=True, max_length=100, blank=False) + name = models.CharField(max_length=250, blank=False) + type = models.ForeignKey(PartnerType) + binary = models.BooleanField(default=True) + source = models.BooleanField(default=True) + enabled = models.BooleanField(default=True) + ftp_dir = models.CharField(max_length=500) + rsync_dir = models.CharField(max_length=500) + + def __unicode__(self): + return u'{0.short} ({0.name})'.format(self) + + def export(self): + result = {'type': self.type.name} + for attr in ['short', 'name', 'binary', 'source', 'enabled', 'ftp_dir', 'rsync_dir']: + result[attr] = getattr(self, attr) + return result + + +class PartnerMapping(models.Model): + partner = models.ForeignKey(Partner) + variant_arch = models.ForeignKey('release.VariantArch') + + class Meta: + unique_together = (('partner', 'variant_arch'), ) + + def __unicode__(self): + return u'{} {} {}'.format(self.partner, self.variant_arch.variant.release, + self.variant_arch) + + def export(self): + return { + 'partner': unicode(self.partner), + 'release': unicode(self.variant_arch.variant.release), + 'variant_arch': unicode(self.variant_arch) + } diff --git a/pdc/apps/partners/serializers.py b/pdc/apps/partners/serializers.py new file mode 100644 index 00000000..eb8f8729 --- /dev/null +++ b/pdc/apps/partners/serializers.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2015 Red Hat +# Licensed under The MIT License (MIT) +# http://opensource.org/licenses/MIT +# +from rest_framework import serializers + +from pdc.apps.common.fields import ChoiceSlugField +from pdc.apps.release import models as release_models +from . import models + + +class PartnerTypeSerializer(serializers.ModelSerializer): + class Meta: + model = models.PartnerType + fields = ('name', ) + + +class PartnerSerializer(serializers.ModelSerializer): + type = ChoiceSlugField(slug_field='name', + queryset=models.PartnerType.objects.all()) + + class Meta: + model = models.Partner + fields = ('short', 'name', 'binary', 'source', 'type', 'enabled', + 'ftp_dir', 'rsync_dir') + + +class PartnerMappingSerializer(serializers.ModelSerializer): + partner = serializers.SlugRelatedField(slug_field='short', + queryset=models.Partner.objects.all()) + release = serializers.CharField(source='variant_arch.variant.release.release_id') + variant = serializers.CharField(source='variant_arch.variant.variant_uid') + arch = serializers.CharField(source='variant_arch.arch.name') + + class Meta: + model = models.PartnerMapping + fields = ('partner', 'release', 'variant', 'arch') + + def validate(self, attrs): + try: + variant_arch = attrs.get('variant_arch', {}) + release_id = variant_arch.get('variant', {}).get('release', {}).get('release_id', '') + variant_uid = variant_arch.get('variant', {}).get('variant_uid', '') + arch = variant_arch.get('arch', {}).get('name', '') + attrs['variant_arch'] = release_models.VariantArch.objects.get( + variant__release__release_id=release_id, + variant__variant_uid=variant_uid, + arch__name=arch + ) + except release_models.VariantArch.DoesNotExist: + raise serializers.ValidationError( + 'No VariantArch for release_id=%s, variant_uid=%s, arch=%s' + % (release_id, variant_uid, arch) + ) + try: + models.PartnerMapping.objects.get(**attrs) + raise serializers.ValidationError('This partners mapping already exists.') + except models.PartnerMapping.DoesNotExist: + pass + return super(PartnerMappingSerializer, self).validate(attrs) diff --git a/pdc/apps/partners/tests.py b/pdc/apps/partners/tests.py new file mode 100644 index 00000000..4947c864 --- /dev/null +++ b/pdc/apps/partners/tests.py @@ -0,0 +1,225 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2015 Red Hat +# Licensed under The MIT License (MIT) +# http://opensource.org/licenses/MIT +# +from rest_framework.test import APITestCase +from rest_framework import status +from django.core.urlresolvers import reverse + +from pdc.apps.common.test_utils import TestCaseWithChangeSetMixin +from pdc.apps.release import models as release_models +from . import models + + +class PartnerTypeTestCase(APITestCase): + @classmethod + def setUpTestData(cls): + models.PartnerType.objects.create(name='customer') + models.PartnerType.objects.create(name='partner') + + def test_can_list_partner_types(self): + response = self.client.get(reverse('partnertype-list')) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 2) + + +class PartnerAPITestCase(TestCaseWithChangeSetMixin, APITestCase): + @classmethod + def setUpTestData(cls): + customer = models.PartnerType.objects.create(name='customer') + models.PartnerType.objects.create(name='partner') + + models.Partner.objects.create(short='acme', name='ACME Corporation', + type=customer) + + def test_create(self): + response = self.client.post(reverse('partner-list'), + {'short': 'jim', 'name': 'Jim Inc.', 'type': 'partner', + 'ftp_dir': 'ftp/dir', 'rsync_dir': 'rsync/dir'}, + format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertDictEqual(response.data, + {'short': 'jim', 'name': 'Jim Inc.', 'type': 'partner', + 'binary': True, 'source': True, 'enabled': True, + 'ftp_dir': 'ftp/dir', 'rsync_dir': 'rsync/dir'}) + self.assertEqual(models.Partner.objects.count(), 2) + self.assertNumChanges([1]) + + def test_create_without_required_fields(self): + response = self.client.post(reverse('partner-list'), + {'short': 'jim', 'ftp_dir': 'ftp/dir', 'rsync_dir': 'rsync/dir'}, + format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('name', response.content) + self.assertIn('type', response.content) + self.assertEqual(models.Partner.objects.count(), 1) + self.assertNumChanges([]) + + def test_create_bad_type(self): + response = self.client.post(reverse('partner-list'), + {'short': 'jim', 'name': 'Jim Inc.', 'type': 'manager', + 'ftp_dir': 'ftp/dir', 'rsync_dir': 'rsync/dir'}, + format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('customer', response.content) + self.assertIn('partner', response.content) + self.assertEqual(models.Partner.objects.count(), 1) + self.assertNumChanges([]) + + def test_retrieve(self): + response = self.client.get(reverse('partner-detail', args=['acme'])) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(dict(response.data), + {'short': 'acme', 'name': 'ACME Corporation', + 'enabled': True, 'binary': True, 'source': True, + 'ftp_dir': '', 'rsync_dir': '', 'type': 'customer'}) + + def test_delete(self): + response = self.client.delete(reverse('partner-detail', args=['acme'])) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(models.Partner.objects.count(), 0) + + def test_partial_update(self): + response = self.client.patch(reverse('partner-detail', args=['acme']), + {'ftp_dir': '/somewhere/over/the/ftp'}, + format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('ftp_dir'), '/somewhere/over/the/ftp') + self.assertNumChanges([1]) + partner = models.Partner.objects.get(short='acme') + self.assertEqual(partner.ftp_dir, '/somewhere/over/the/ftp') + + def test_update(self): + data = {'short': 'acme', 'name': 'ACME Inc.', 'type': 'partner', + 'enabled': True, 'binary': False, 'source': False, + 'ftp_dir': 'ftp', 'rsync_dir': 'rsync'} + response = self.client.put(reverse('partner-detail', args=['acme']), + data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertNumChanges([1]) + self.assertDictEqual(response.data, data) + + def test_update_short(self): + response = self.client.patch(reverse('partner-detail', args=['acme']), + {'short': 'emca'}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('short'), 'emca') + self.assertNumChanges([1]) + self.assertEqual(models.Partner.objects.filter(short='acme').count(), 0) + self.assertEqual(models.Partner.objects.filter(short='emca').count(), 1) + + def test_filter_by_short(self): + response = self.client.get(reverse('partner-list'), {'short': 'foo'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 0) + + def test_filter_by_enabled(self): + response = self.client.get(reverse('partner-list'), {'enabled': False}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 0) + + def test_filter_by_binary(self): + response = self.client.get(reverse('partner-list'), {'binary': False}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 0) + + def test_filter_by_source(self): + response = self.client.get(reverse('partner-list'), {'source': False}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 0) + + def test_filter_by_ftp_dir(self): + response = self.client.get(reverse('partner-list'), {'ftp_dir': 'something'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 0) + + def test_filter_by_rsync_dir(self): + response = self.client.get(reverse('partner-list'), {'rsync_dir': 'something'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 0) + + +class PartnerMappingAPITestCase(TestCaseWithChangeSetMixin, APITestCase): + fixtures = [ + "pdc/apps/release/fixtures/tests/variants_standalone.json", + ] + + @classmethod + def setUpTestData(cls): + models.PartnerType.objects.create(name='customer') + partner = models.PartnerType.objects.create(name='partner') + + p = models.Partner.objects.create(short='acme', name='ACME Corporation', + type=partner) + va = release_models.VariantArch.objects.get(pk=1) + models.PartnerMapping.objects.create(partner=p, variant_arch=va) + + def test_create(self): + response = self.client.post(reverse('partnermapping-list'), + {'partner': 'acme', 'release': 'release-1.0', + 'variant': 'Client-UID', 'arch': 'x86_64'}, + format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertNumChanges([1]) + self.assertEqual(models.PartnerMapping.objects.count(), 2) + + def test_list(self): + response = self.client.get(reverse('partnermapping-list')) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 1) + + def test_filter_by_partner(self): + response = self.client.get(reverse('partnermapping-list'), {'partner': 'foo'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 0) + + def test_filter_by_release(self): + response = self.client.get(reverse('partnermapping-list'), {'release': 'release-1.1'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 0) + + def test_filter_by_variant(self): + response = self.client.get(reverse('partnermapping-list'), {'variant': 'Client-UID'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 0) + + def test_filter_by_arch(self): + response = self.client.get(reverse('partnermapping-list'), {'variant': 'i386'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 0) + + def test_delete(self): + url = reverse('partnermapping-detail', args=['acme/release-1.0/Server-UID/x86_64']) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertNumChanges([1]) + self.assertEqual(models.PartnerMapping.objects.count(), 0) + + def test_create_with_variant_not_in_release(self): + response = self.client.post(reverse('partnermapping-list'), + {'partner': 'acme', 'release': 'release-1.0', + 'variant': 'Whatever', 'arch': 'x86_64'}, + format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertNumChanges([]) + self.assertEqual(models.PartnerMapping.objects.count(), 1) + + def test_create_with_non_existing_partner(self): + response = self.client.post(reverse('partnermapping-list'), + {'partner': 'foo', 'release': 'release-1.0', + 'variant': 'Server-UID', 'arch': 'x86_64'}, + format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertNumChanges([]) + self.assertEqual(models.PartnerMapping.objects.count(), 1) + + def test_create_duplicit(self): + response = self.client.post(reverse('partnermapping-list'), + {'partner': 'acme', 'release': 'release-1.0', + 'variant': 'Server-UID', 'arch': 'x86_64'}, + format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertNumChanges([]) + self.assertEqual(models.PartnerMapping.objects.count(), 1) diff --git a/pdc/apps/partners/views.py b/pdc/apps/partners/views.py new file mode 100644 index 00000000..67d47776 --- /dev/null +++ b/pdc/apps/partners/views.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2015 Red Hat +# Licensed under The MIT License (MIT) +# http://opensource.org/licenses/MIT +# +from rest_framework import viewsets, mixins + +from pdc.apps.common.viewsets import (StrictQueryParamMixin, + ChangeSetCreateModelMixin, + ChangeSetDestroyModelMixin, + MultiLookupFieldMixin, + PDCModelViewSet) + +from . import filters +from . import models +from . import serializers + + +class PartnerTypeViewSet(StrictQueryParamMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet): + queryset = models.PartnerType.objects.all() + serializer_class = serializers.PartnerTypeSerializer + + def list(self, request, *args, **kwargs): + """ + __Method__: `GET` + + __URL__: $LINK:partnertype-list$ + + __Response__: a paged list of following objects + + %(SERIALIZER)s + """ + return super(PartnerTypeViewSet, self).list(request, *args, **kwargs) + + +class PartnerViewSet(PDCModelViewSet): + queryset = models.Partner.objects.all() + lookup_field = 'short' + serializer_class = serializers.PartnerSerializer + filter_class = filters.PartnerFilterSet + + def create(self, *args, **kwargs): + """ + __Method__: `POST` + + __URL__: $LINK:partner-list$ + + __Data__: + + %(WRITABLE_SERIALIZER)s + + See list of [available partner types]($URL:partnertype-list$). + + __Response__: + + %(SERIALIZER)s + """ + return super(PartnerViewSet, self).create(*args, **kwargs) + + def retrieve(self, *args, **kwargs): + """ + __Method__: `GET` + + __URL__: $LINK:partner-detail:short$ + + __Response__: + + %(SERIALIZER)s + """ + return super(PartnerViewSet, self).retrieve(*args, **kwargs) + + def list(self, *args, **kwargs): + """ + __Method__: `GET` + + __URL__: $LINK:partner-list$ + + __Query params__: + + %(FILTERS)s + + __Response__: a paged list of following objects + + %(SERIALIZER)s + """ + return super(PartnerViewSet, self).list(*args, **kwargs) + + def destroy(self, *args, **kwargs): + """ + __Method__: `DELETE` + + __URL__: $LINK:partner-detail:short$ + """ + return super(PartnerViewSet, self).destroy(*args, **kwargs) + + def update(self, *args, **kwargs): + """ + __Method__: `PUT`, `PATCH` + + __URL__: $LINK:partner-detail:short$ + + __Data__: + + %(WRITABLE_SERIALIZER)s + + __Response__: + + %(SERIALIZER)s + """ + return super(PartnerViewSet, self).update(*args, **kwargs) + + +class PartnerMappingViewSet(StrictQueryParamMixin, + MultiLookupFieldMixin, + mixins.ListModelMixin, + ChangeSetCreateModelMixin, + ChangeSetDestroyModelMixin, + viewsets.GenericViewSet): + queryset = models.PartnerMapping.objects.all().select_related( + 'partner', + 'variant_arch__arch', + 'variant_arch__variant__release' + ) + serializer_class = serializers.PartnerMappingSerializer + lookup_fields = ( + ('partner__short', r'[^/]+'), + ('variant_arch__variant__release__release_id', r'[^/]+'), + ('variant_arch__variant__variant_uid', r'[^/]+'), + ('variant_arch__arch__name', r'[^/]+'), + ) + filter_class = filters.PartnerMappingFilterSet + + def list(self, *args, **kwargs): + """Get mappings between partner and releases. + + __Method__: `GET` + + __Query params__: + + %(FILTERS)s + + __Response__: a paged list of following objects + + %(SERIALIZER)s + """ + return super(PartnerMappingViewSet, self).list(*args, **kwargs) + + def create(self, *args, **kwargs): + """Create mapping between partner and specified Variant.Arch on a given release. + + __Method__: `POST` + + __Data__: + + %(WRITABLE_SERIALIZER)s + + __Response__: + + %(SERIALIZER)s + """ + return super(PartnerMappingViewSet, self).create(*args, **kwargs) + + def destroy(self, *args, **kwargs): + """Delete mapping between partner and specified Variant.Arch on a given release. + + __Method__: `DELETE` + + __URL__: $LINK:partnermapping-detail:partner}/{release_id}/{variant}/{arch$ + """ + return super(PartnerMappingViewSet, self).destroy(*args, **kwargs) diff --git a/pdc/routers.py b/pdc/routers.py index eba218e4..0a109587 100644 --- a/pdc/routers.py +++ b/pdc/routers.py @@ -15,6 +15,7 @@ from pdc.apps.package import views as rpm_views from pdc.apps.utils import SortedRouter from pdc.apps.osbs import views as osbs_views +from pdc.apps.partners import views as partner_views router = SortedRouter.PDCRouter() @@ -162,3 +163,10 @@ router.register(r'release-component-contacts', component_views.ReleaseComponentContactInfoViewSet, base_name='releasecomponentcontacts') + +router.register(r'partner-types', + partner_views.PartnerTypeViewSet) +router.register(r'partners', + partner_views.PartnerViewSet) +router.register(r'partners-mapping', + partner_views.PartnerMappingViewSet) diff --git a/pdc/settings.py b/pdc/settings.py index e58b7167..8a5b9998 100644 --- a/pdc/settings.py +++ b/pdc/settings.py @@ -61,6 +61,7 @@ 'pdc.apps.bindings', 'pdc.apps.usage', 'pdc.apps.osbs', + 'pdc.apps.partners', 'mptt', )