From 816f912e0625864c637d9f1ae2b0cc901eb0560e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lubom=C3=ADr=20Sedl=C3=A1=C5=99?= Date: Tue, 1 Sep 2015 09:19:01 +0200 Subject: [PATCH] Enable storing RPM dependencies Each RPM can have arbitrary number of dependencies of multiple types. The types are hard-coded, and can not be extended at run-time. With this patch, it is possible to store and retrieve the dependencies. There are tests for these parts. Existing tests have been updated to pass: for RPM tests there are some minor updates. The compose/package API and its variants are updated not to include dependencies in their output. All changes to dependencies are logged in change sets as single update to the RPM. There is a single filter for each dependency type. It allows to specify an optional version. When a version is used in the filter, it removes all packages that are incompatible with that version restriction. The behaviour is documented with examples. The version comparisons are implemented with a function adhering to PEP 0440 [0], which seems to be powerful enough to do everything required. There is a wrapper that makes it work with epochs. It also works with releases. There is an additional single filter `has_no_deps`, which filters packages that have some or don't have any dependency (this works across all types). [0]: https://www.python.org/dev/peps/pep-0440/ Changes in other parts of codebase: * Custom boolean filter is updated to allow removing duplicates from the response. JIRA: PDC-955 --- pdc/apps/common/filters.py | 9 +- pdc/apps/common/hacks.py | 13 + pdc/apps/compose/views.py | 4 +- pdc/apps/package/filters.py | 64 +- .../migrations/0005_auto_20150907_0905.py | 29 + pdc/apps/package/models.py | 120 +++- pdc/apps/package/serializers.py | 59 +- pdc/apps/package/tests.py | 666 +++++++++++++++++- pdc/apps/package/views.py | 32 + 9 files changed, 975 insertions(+), 21 deletions(-) create mode 100644 pdc/apps/package/migrations/0005_auto_20150907_0905.py diff --git a/pdc/apps/common/filters.py b/pdc/apps/common/filters.py index 8ad3c2c2..b728ef58 100644 --- a/pdc/apps/common/filters.py +++ b/pdc/apps/common/filters.py @@ -228,8 +228,11 @@ def filter(self, qs, value): if not value: return qs if value.lower() in self.TRUE_STRINGS: - return qs.filter(**{self.name: True}) + qs = qs.filter(**{self.name: True}) elif value.lower() in self.FALSE_STRINGS: - return qs.filter(**{self.name: False}) + qs = qs.filter(**{self.name: False}) else: - return qs.none() + qs = qs.none() + if self.distinct: + qs = qs.distinct() + return qs diff --git a/pdc/apps/common/hacks.py b/pdc/apps/common/hacks.py index 81d02afc..766215fd 100644 --- a/pdc/apps/common/hacks.py +++ b/pdc/apps/common/hacks.py @@ -4,12 +4,15 @@ # Licensed under The MIT License (MIT) # http://opensource.org/licenses/MIT # +import re + from django.db import connection from django.conf import settings from django.core.exceptions import ValidationError from rest_framework import serializers from productmd import composeinfo, images, rpms +from pkg_resources import parse_version def composeinfo_from_str(data): @@ -113,3 +116,13 @@ def srpm_name_to_component_names(srpm_name): return binding_models.ReleaseComponentSRPMNameMapping.get_component_names_by_srpm_name(srpm_name) else: return [srpm_name] + + +def parse_epoch_version(version): + """ + Wrapper around `pkg_resources.parse_version` that can handle epochs + delimited by colon as is customary for RPMs. + """ + if re.match(r'^\d+:', version): + version = re.sub(r'^(\d+):', r'\1!', version) + return parse_version(version) diff --git a/pdc/apps/compose/views.py b/pdc/apps/compose/views.py index b69eb7e6..23c50889 100644 --- a/pdc/apps/compose/views.py +++ b/pdc/apps/compose/views.py @@ -1148,7 +1148,9 @@ def _packages_output(self, rpms): Output packages with unicode or dict """ packages = [unicode(rpm) for rpm in rpms] - return packages if not self.to_dict else [RPMSerializer(rpm).data for rpm in rpms] + return (packages + if not self.to_dict + else [RPMSerializer(rpm, exclude_fields=['dependencies']).data for rpm in rpms]) def _get_query_param_or_false(self, request, query_str): value = request.query_params.get(query_str) diff --git a/pdc/apps/package/filters.py b/pdc/apps/package/filters.py index b8bd9908..7051d23e 100644 --- a/pdc/apps/package/filters.py +++ b/pdc/apps/package/filters.py @@ -3,16 +3,59 @@ # Licensed under The MIT License (MIT) # http://opensource.org/licenses/MIT # +from functools import partial + from django.conf import settings from django.forms import SelectMultiple +from django.core.exceptions import FieldError import django_filters -from pdc.apps.common.filters import MultiValueFilter, MultiIntFilter, NullableCharFilter -from pdc.apps.common.filters import CaseInsensitiveBooleanFilter +from pdc.apps.common.filters import (MultiValueFilter, MultiIntFilter, + NullableCharFilter, CaseInsensitiveBooleanFilter) from . import models +def dependency_filter(type, queryset, value): + m = models.Dependency.DEPENDENCY_PARSER.match(value) + if not m: + raise FieldError('Unrecognized value for filter for {}'.format(type)) + groups = m.groupdict() + queryset = queryset.filter(dependency__name=groups['name'], dependency__type=type).distinct() + for dep in models.Dependency.objects.filter(type=type, name=groups['name']): + + is_equal = dep.is_equal(groups['version']) if groups['version'] else False + is_lower = dep.is_lower(groups['version']) if groups['version'] else False + is_higher = dep.is_higher(groups['version']) if groups['version'] else False + + if groups['op'] == '=' and not dep.is_satisfied_by(groups['version']): + queryset = queryset.exclude(pk=dep.rpm_id) + + # User requests everything depending on higher than X + elif groups['op'] == '>' and dep.comparison in ('<', '<=', '=') and (is_lower or is_equal): + queryset = queryset.exclude(pk=dep.rpm_id) + + # User requests everything depending on lesser than X + elif groups['op'] == '<' and dep.comparison in ('>', '>=', '=') and (is_higher or is_equal): + queryset = queryset.exclude(pk=dep.rpm_id) + + # User requests everything depending on at least X + elif groups['op'] == '>=': + if dep.comparison == '<' and (is_lower or is_equal): + queryset = queryset.exclude(pk=dep.rpm_id) + elif dep.comparison in ('<=', '=') and is_lower: + queryset = queryset.exclude(pk=dep.rpm_id) + + # User requests everything depending on at most X + elif groups['op'] == '<=': + if dep.comparison == '>' and (is_higher or is_equal): + queryset = queryset.exclude(pk=dep.rpm_id) + elif dep.comparison in ('>=', '=') and is_higher: + queryset = queryset.exclude(pk=dep.rpm_id) + + return queryset + + class RPMFilter(django_filters.FilterSet): name = MultiValueFilter() version = MultiValueFilter() @@ -25,11 +68,26 @@ class RPMFilter(django_filters.FilterSet): compose = MultiValueFilter(name='composerpm__variant_arch__variant__compose__compose_id', distinct=True) linked_release = MultiValueFilter(name='linked_releases__release_id', distinct=True) + provides = django_filters.MethodFilter(action=partial(dependency_filter, + models.Dependency.PROVIDES)) + requires = django_filters.MethodFilter(action=partial(dependency_filter, + models.Dependency.REQUIRES)) + obsoletes = django_filters.MethodFilter(action=partial(dependency_filter, + models.Dependency.OBSOLETES)) + conflicts = django_filters.MethodFilter(action=partial(dependency_filter, + models.Dependency.CONFLICTS)) + recommends = django_filters.MethodFilter(action=partial(dependency_filter, + models.Dependency.RECOMMENDS)) + suggests = django_filters.MethodFilter(action=partial(dependency_filter, + models.Dependency.SUGGESTS)) + has_no_deps = CaseInsensitiveBooleanFilter(name='dependency__isnull', distinct=True) class Meta: model = models.RPM fields = ('name', 'version', 'epoch', 'release', 'arch', 'srpm_name', - 'srpm_nevra', 'compose', 'filename', 'linked_release') + 'srpm_nevra', 'compose', 'filename', 'linked_release', + 'provides', 'requires', 'obsoletes', 'conflicts', 'recommends', 'suggests', + 'has_no_deps') class ImageFilter(django_filters.FilterSet): diff --git a/pdc/apps/package/migrations/0005_auto_20150907_0905.py b/pdc/apps/package/migrations/0005_auto_20150907_0905.py new file mode 100644 index 00000000..c12d283b --- /dev/null +++ b/pdc/apps/package/migrations/0005_auto_20150907_0905.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('package', '0004_rpm_linked_releases'), + ] + + operations = [ + migrations.CreateModel( + name='Dependency', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('type', models.PositiveIntegerField(choices=[(1, b'provides'), (2, b'requires'), (3, b'obsoletes'), (4, b'conflicts'), (5, b'recommends'), (6, b'suggests')])), + ('name', models.CharField(max_length=200)), + ('version', models.CharField(max_length=200, null=True, blank=True)), + ('comparison', models.CharField(max_length=50, null=True, blank=True)), + ('rpm', models.ForeignKey(to='package.RPM')), + ], + ), + migrations.AlterUniqueTogether( + name='dependency', + unique_together=set([('type', 'name', 'version', 'comparison', 'rpm')]), + ), + ] diff --git a/pdc/apps/package/models.py b/pdc/apps/package/models.py index 671c087d..461f2863 100644 --- a/pdc/apps/package/models.py +++ b/pdc/apps/package/models.py @@ -3,17 +3,18 @@ # Licensed under The MIT License (MIT) # http://opensource.org/licenses/MIT # +import re + from django.db import models, connection, transaction from django.db.utils import IntegrityError from django.core.exceptions import ValidationError from django.forms.models import model_to_dict -from pkg_resources import parse_version from kobo.rpmlib import parse_nvra from pdc.apps.common.models import get_cached_id from pdc.apps.common.validators import validate_md5, validate_sha1, validate_sha256 -from pdc.apps.common.hacks import add_returning +from pdc.apps.common.hacks import add_returning, parse_epoch_version from pdc.apps.common.constants import ARCH_SRC from pdc.apps.release.models import Release @@ -54,13 +55,16 @@ def check_srpm_nevra(srpm_nevra, arch): raise ValidationError("RPM's srpm_nevra should be empty if and only if arch is src") def export(self, fields=None): - _fields = set(['name', 'epoch', 'version', 'release', 'arch', 'filename', - 'srpm_name', 'srpm_nevra', 'linked_releases']) if fields is None else set(fields) + _fields = (set(['name', 'epoch', 'version', 'release', 'arch', 'filename', + 'srpm_name', 'srpm_nevra', 'linked_releases', 'dependencies']) + if fields is None else set(fields)) result = model_to_dict(self, fields=_fields - {'linked_releases'}) if 'linked_releases' in _fields: result['linked_releases'] = [] for linked_release in self.linked_releases.all(): result['linked_releases'].append(linked_release.release_id) + if 'dependencies' in _fields: + result['dependencies'] = self.dependencies return result @staticmethod @@ -109,7 +113,113 @@ def bulk_insert(cursor, rpm_nevra, filename, srpm_nevra=None): @property def sort_key(self): - return (self.epoch, parse_version(self.version), parse_version(self.release)) + return (self.epoch, parse_epoch_version(self.version), parse_epoch_version(self.release)) + + @property + def dependencies(self): + """ + Get a dict with all deps of the RPM. All types of dependencies are + included. + """ + result = {} + choices = dict(Dependency.DEPENDENCY_TYPE_CHOICES) + for type in choices.values(): + result[type] = [] + for dep in Dependency.objects.filter(rpm=self): + result[choices[dep.type]].append(unicode(dep)) + return result + + +class Dependency(models.Model): + PROVIDES = 1 + REQUIRES = 2 + OBSOLETES = 3 + CONFLICTS = 4 + RECOMMENDS = 5 + SUGGESTS = 6 + DEPENDENCY_TYPE_CHOICES = ( + (PROVIDES, 'provides'), + (REQUIRES, 'requires'), + (OBSOLETES, 'obsoletes'), + (CONFLICTS, 'conflicts'), + (RECOMMENDS, 'recommends'), + (SUGGESTS, 'suggests'), + ) + + DEPENDENCY_PARSER = re.compile(r'^(?P[^ <>=]+)( *(?P=|>=|<=|<|>) *(?P[^ <>=]+))?$') + + type = models.PositiveIntegerField(choices=DEPENDENCY_TYPE_CHOICES) + name = models.CharField(max_length=200) + version = models.CharField(max_length=200, blank=True, null=True) + comparison = models.CharField(max_length=50, blank=True, null=True) + rpm = models.ForeignKey(RPM) + + class Meta: + unique_together = ( + ('type', 'name', 'version', 'comparison', 'rpm') + ) + + def __unicode__(self): + base_str = self.name + if self.version: + base_str += ' {comparison} {version}'.format(comparison=self.comparison, + version=self.version) + return base_str + + def clean(self): + """ + When version constraint is set, both a version and comparison type must + be specified. + """ + if (self.version is None) != (self.comparison is None): + # This code should be unreachable based on user input, and only + # programmer error can cause this to fail. + raise ValidationError('Bad version constraint: both version and comparison must be specified.') + + @property + def parsed_version(self): + if not hasattr(self, '_version'): + self._version = parse_epoch_version(self.version) + return self._version + + def is_satisfied_by(self, other): + """ + Check if other version satisfies this dependency. + + :paramtype other: string + """ + funcs = { + '=': lambda x: x == self.parsed_version, + '<': lambda x: x < self.parsed_version, + '<=': lambda x: x <= self.parsed_version, + '>': lambda x: x > self.parsed_version, + '>=': lambda x: x >= self.parsed_version, + } + return funcs[self.comparison](parse_epoch_version(other)) + + def is_equal(self, other): + """ + Return true if the other version is equal to version in this dep. + + :paramtype other: string + """ + return self.parsed_version == parse_epoch_version(other) + + def is_higher(self, other): + """ + Return true if version in this dep is higher than the other version. + + :paramtype other: string + """ + return self.parsed_version > parse_epoch_version(other) + + def is_lower(self, other): + """ + Return true if version in this dep is lower than the other version. + + :paramtype other: string + """ + return self.parsed_version < parse_epoch_version(other) class ImageFormat(models.Model): diff --git a/pdc/apps/package/serializers.py b/pdc/apps/package/serializers.py index dbb89a08..26b3e4e0 100644 --- a/pdc/apps/package/serializers.py +++ b/pdc/apps/package/serializers.py @@ -9,7 +9,7 @@ from rest_framework import serializers from . import models -from pdc.apps.common.serializers import StrictSerializerMixin +from pdc.apps.common.serializers import StrictSerializerMixin, DynamicFieldsSerializerMixin class DefaultFilenameGenerator(object): @@ -20,16 +20,67 @@ def set_context(self, field): self.field = field -class RPMSerializer(StrictSerializerMixin, serializers.ModelSerializer): +class DependencySerializer(serializers.BaseSerializer): + doc_format = '{"dependency type (string)": "string"}' + + def to_representation(self, deps): + return deps + + def to_internal_value(self, data): + choices = dict([(y, x) for (x, y) in models.Dependency.DEPENDENCY_TYPE_CHOICES]) + result = [] + for key in data: + if key not in choices: + raise serializers.ValidationError('<{}> is not a known dependency type.'.format(key)) + type = choices[key] + if not isinstance(data[key], list): + raise serializers.ValidationError('Value for <{}> is not a list.'.format(key)) + result.extend([self._dep_to_internal(type, key, dep) for dep in data[key]]) + return result + + def _dep_to_internal(self, type, human_type, data): + if not isinstance(data, basestring): + raise serializers.ValidationError('Dependency <{}> for <{}> is not a string.'.format(data, human_type)) + m = models.Dependency.DEPENDENCY_PARSER.match(data) + if not m: + raise serializers.ValidationError('Dependency <{}> for <{}> has bad format.'.format(data, human_type)) + groups = m.groupdict() + return models.Dependency(name=groups['name'], type=type, + comparison=groups.get('op'), version=groups.get('version')) + + +class RPMSerializer(StrictSerializerMixin, + DynamicFieldsSerializerMixin, + serializers.ModelSerializer): filename = serializers.CharField(default=DefaultFilenameGenerator()) linked_releases = serializers.SlugRelatedField(many=True, slug_field='release_id', queryset=models.Release.objects.all(), required=False) linked_composes = serializers.SlugRelatedField(read_only=True, slug_field='compose_id', many=True) + dependencies = DependencySerializer(required=False, default={}) class Meta: model = models.RPM - fields = ('id', 'name', 'version', 'epoch', 'release', 'arch', 'srpm_name', 'srpm_nevra', 'filename', - 'linked_releases', 'linked_composes') + fields = ('id', 'name', 'version', 'epoch', 'release', 'arch', 'srpm_name', + 'srpm_nevra', 'filename', 'linked_releases', 'linked_composes', + 'dependencies') + + def create(self, validated_data): + dependencies = validated_data.pop('dependencies', []) + instance = super(RPMSerializer, self).create(validated_data) + for dep in dependencies: + dep.rpm = instance + dep.save() + return instance + + def update(self, instance, validated_data): + dependencies = validated_data.pop('dependencies', None) + instance = super(RPMSerializer, self).update(instance, validated_data) + if dependencies is not None or not self.partial: + models.Dependency.objects.filter(rpm=instance).delete() + for dep in dependencies or []: + dep.rpm = instance + dep.save() + return instance class ImageSerializer(StrictSerializerMixin, serializers.ModelSerializer): diff --git a/pdc/apps/package/tests.py b/pdc/apps/package/tests.py index 5b4883fc..ca17d662 100644 --- a/pdc/apps/package/tests.py +++ b/pdc/apps/package/tests.py @@ -80,6 +80,656 @@ def test_empty_srpm_nevra_with_arch_is_not_src(self): self.assertEqual(0, models.RPM.objects.count()) +class RPMDepsFilterAPITestCase(APITestCase): + def setUp(self): + """ + 15 packages are created. They all have name test-X, where X is a + number. Each packages has a dependency of each type with the same + constraint. They are summarized in the table below. + + 0 (=1.0) 1 (<1.0) 2 (>1.0) 3 (<=1.0) 4 (>=1.0) + 5 (=2.0) 6 (<2.0) 7 (>2.0) 8 (<=2.0) 9 (>=2.0) + 10 (=3.0) 11 (<3.0) 12 (>3.0) 13 (<=3.0) 14 (>=3.0) + """ + counter = 0 + for version in ['1.0', '2.0', '3.0']: + for op in '= < > <= >='.split(): + name = 'test-{counter}'.format(counter=counter) + counter += 1 + rpm = models.RPM.objects.create(name=name, epoch=0, version='1.0', + release='1', arch='x86_64', srpm_name='test-pkg', + srpm_nevra='test-pkg-1.0.1.x86_64', + filename='dummy') + for type in [t[0] for t in models.Dependency.DEPENDENCY_TYPE_CHOICES]: + rpm.dependency_set.create(name='pkg', version=version, + type=type, comparison=op) + + # + # No contraint tests + # + + def test_filter_without_version_requires(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 15) + + def test_filter_without_version_suggests(self): + response = self.client.get(reverse('rpms-list'), {'suggests': 'pkg'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 15) + + def test_filter_without_version_obsoletes(self): + response = self.client.get(reverse('rpms-list'), {'obsoletes': 'pkg'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 15) + + def test_filter_without_version_recommends(self): + response = self.client.get(reverse('rpms-list'), {'recommends': 'pkg'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 15) + + def test_filter_without_version_provides(self): + response = self.client.get(reverse('rpms-list'), {'provides': 'pkg'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 15) + + def test_filter_without_version_conflicts(self): + response = self.client.get(reverse('rpms-list'), {'conflicts': 'pkg'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 15) + + # + # Equality contraint tests + # + + def test_filter_with_version_equality_requires(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [2, 4, 5, 8, 9, 11, 13]]) + + def test_filter_with_version_equality_suggests(self): + response = self.client.get(reverse('rpms-list'), {'suggests': 'pkg=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [2, 4, 5, 8, 9, 11, 13]]) + + def test_filter_with_version_equality_obsoletes(self): + response = self.client.get(reverse('rpms-list'), {'obsoletes': 'pkg=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [2, 4, 5, 8, 9, 11, 13]]) + + def test_filter_with_version_equality_recommends(self): + response = self.client.get(reverse('rpms-list'), {'recommends': 'pkg=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [2, 4, 5, 8, 9, 11, 13]]) + + def test_filter_with_version_equality_provides(self): + response = self.client.get(reverse('rpms-list'), {'provides': 'pkg=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [2, 4, 5, 8, 9, 11, 13]]) + + def test_filter_with_version_equality_conflicts(self): + response = self.client.get(reverse('rpms-list'), {'conflicts': 'pkg=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [2, 4, 5, 8, 9, 11, 13]]) + + # + # Greater than constraint tests + # + + def test_filter_with_greater_version_requires(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg>2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [2, 4, 7, 9, 10, 11, 12, 13, 14]]) + + def test_filter_with_greater_version_suggests(self): + response = self.client.get(reverse('rpms-list'), {'suggests': 'pkg>2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [2, 4, 7, 9, 10, 11, 12, 13, 14]]) + + def test_filter_with_greater_version_obsoletes(self): + response = self.client.get(reverse('rpms-list'), {'obsoletes': 'pkg>2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [2, 4, 7, 9, 10, 11, 12, 13, 14]]) + + def test_filter_with_greater_version_recommends(self): + response = self.client.get(reverse('rpms-list'), {'recommends': 'pkg>2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [2, 4, 7, 9, 10, 11, 12, 13, 14]]) + + def test_filter_with_greater_version_provides(self): + response = self.client.get(reverse('rpms-list'), {'provides': 'pkg>2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [2, 4, 7, 9, 10, 11, 12, 13, 14]]) + + def test_filter_with_greater_version_conflicts(self): + response = self.client.get(reverse('rpms-list'), {'conflicts': 'pkg>2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [2, 4, 7, 9, 10, 11, 12, 13, 14]]) + + # + # Lesser than constraint tests + # + + def test_filter_with_lesser_version_requires(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg<2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [0, 1, 2, 3, 4, 6, 8, 11, 13]]) + + def test_filter_with_lesser_version_suggests(self): + response = self.client.get(reverse('rpms-list'), {'suggests': 'pkg<2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [0, 1, 2, 3, 4, 6, 8, 11, 13]]) + + def test_filter_with_lesser_version_obsoletes(self): + response = self.client.get(reverse('rpms-list'), {'obsoletes': 'pkg<2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [0, 1, 2, 3, 4, 6, 8, 11, 13]]) + + def test_filter_with_lesser_version_recommends(self): + response = self.client.get(reverse('rpms-list'), {'recommends': 'pkg<2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [0, 1, 2, 3, 4, 6, 8, 11, 13]]) + + def test_filter_with_lesser_version_provides(self): + response = self.client.get(reverse('rpms-list'), {'provides': 'pkg<2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [0, 1, 2, 3, 4, 6, 8, 11, 13]]) + + def test_filter_with_lesser_version_conflicts(self): + response = self.client.get(reverse('rpms-list'), {'conflicts': 'pkg<2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [0, 1, 2, 3, 4, 6, 8, 11, 13]]) + + # + # Greater than or equal constraint tests + # + + def test_filter_with_greater_or_equal_version_requires(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg>=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [2, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14]]) + + def test_filter_with_greater_or_equal_version_suggests(self): + response = self.client.get(reverse('rpms-list'), {'suggests': 'pkg>=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [2, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14]]) + + def test_filter_with_greater_or_equal_version_recommends(self): + response = self.client.get(reverse('rpms-list'), {'recommends': 'pkg>=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [2, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14]]) + + def test_filter_with_greater_or_equal_version_obsoletes(self): + response = self.client.get(reverse('rpms-list'), {'obsoletes': 'pkg>=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [2, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14]]) + + def test_filter_with_greater_or_equal_version_provides(self): + response = self.client.get(reverse('rpms-list'), {'provides': 'pkg>=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [2, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14]]) + + def test_filter_with_greater_or_equal_version_conflicts(self): + response = self.client.get(reverse('rpms-list'), {'conflicts': 'pkg>=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [2, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14]]) + + # + # Lesser than or equal constraint tests + # + + def test_filter_with_lesser_or_equal_version_requires(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg<=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [0, 1, 2, 3, 4, 5, 6, 8, 9, 11, 13]]) + + def test_filter_with_lesser_or_equal_version_suggests(self): + response = self.client.get(reverse('rpms-list'), {'suggests': 'pkg<=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [0, 1, 2, 3, 4, 5, 6, 8, 9, 11, 13]]) + + def test_filter_with_lesser_or_equal_version_recommends(self): + response = self.client.get(reverse('rpms-list'), {'recommends': 'pkg<=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [0, 1, 2, 3, 4, 5, 6, 8, 9, 11, 13]]) + + def test_filter_with_lesser_or_equal_version_provides(self): + response = self.client.get(reverse('rpms-list'), {'provides': 'pkg<=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [0, 1, 2, 3, 4, 5, 6, 8, 9, 11, 13]]) + + def test_filter_with_lesser_or_equal_version_conflicts(self): + response = self.client.get(reverse('rpms-list'), {'conflicts': 'pkg<=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [0, 1, 2, 3, 4, 5, 6, 8, 9, 11, 13]]) + + def test_filter_with_lesser_or_equal_version_obsoletes(self): + response = self.client.get(reverse('rpms-list'), {'obsoletes': 'pkg<=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertItemsEqual([pkg['name'] for pkg in response.data['results']], + ['test-{}'.format(i) for i in [0, 1, 2, 3, 4, 5, 6, 8, 9, 11, 13]]) + + +class RPMDepsFilterWithReleaseTestCase(APITestCase): + def setUp(self): + self.rpm = models.RPM.objects.create(name='test-pkg', epoch=0, version='1.0', + release='1', arch='x86_64', srpm_name='test-pkg', + srpm_nevra='test-pkg-1.0.1.x86_64', + filename='dummy') + self.rpm.dependency_set.create(name='pkg', version='3.0-1.fc22', + type=models.Dependency.REQUIRES, comparison='=') + + def test_filter_with_same_release_equal(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg=3.0-1.fc22'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 1) + + def test_filter_with_same_release_lesser(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg<3.0-1.fc22'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 0) + + def test_filter_with_same_release_greater(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg>3.0-1.fc22'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 0) + + def test_filter_with_same_release_lesser_equal(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg<=3.0-1.fc22'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 1) + + def test_filter_with_same_release_greater_equal(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg>=3.0-1.fc22'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 1) + + def test_filter_with_different_release_equal(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg=3.0-2.fc22'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 0) + + def test_filter_with_different_release_lesser(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg<3.0-2.fc22'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 1) + + def test_filter_with_different_release_greater(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg>3.0-2.fc22'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 0) + + def test_filter_with_different_release_lesser_equal(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg<=3.0-2.fc22'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 1) + + def test_filter_with_different_release_greater_equal(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg>=3.0-2.fc22'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 0) + + +class RPMDepsFilterWithEpochTestCase(APITestCase): + def setUp(self): + self.rpm = models.RPM.objects.create(name='test-pkg', epoch=0, version='1.0', + release='1', arch='x86_64', srpm_name='test-pkg', + srpm_nevra='test-pkg-1.0.1.x86_64', + filename='dummy') + self.rpm.dependency_set.create(name='pkg', version='3.0', + type=models.Dependency.REQUIRES, comparison='=') + + def test_filter_with_same_epoch_equal(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg=0:3.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 1) + + def test_filter_with_same_epoch_lesser(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg<0:4.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 1) + + def test_filter_with_same_epoch_greater(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg>0:2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 1) + + def test_filter_with_same_epoch_lesser_equal(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg<=0:3.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 1) + + def test_filter_with_same_epoch_greater_equal(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg>=0:3.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 1) + + def test_filter_with_different_epoch_equal(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg=1:3.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 0) + + def test_filter_with_different_epoch_lesser(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg<1:3.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 1) + + def test_filter_with_different_epoch_greater(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg>1:2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 0) + + def test_filter_with_different_epoch_lesser_equal(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg<=1:3.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 1) + + def test_filter_with_different_epoch_greater_equal(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg>=1:3.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 0) + + +class RPMDepsFilterRangeAPITestCase(APITestCase): + def setUp(self): + rpm = models.RPM.objects.create(name='test-pkg', epoch=0, version='1.0', + release='1', arch='x86_64', srpm_name='test-pkg', + srpm_nevra='test-pkg-1.0.1.x86_64', + filename='dummy') + for type in [t[0] for t in models.Dependency.DEPENDENCY_TYPE_CHOICES]: + rpm.dependency_set.create(name='pkg', version='1.0', + type=type, comparison='>=') + rpm.dependency_set.create(name='pkg', version='3.0', + type=type, comparison='<') + + def test_filter_with_range_match_requires(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 1) + + def test_filter_with_range_no_match_requires(self): + response = self.client.get(reverse('rpms-list'), {'requires': 'pkg=4.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 0) + + def test_filter_with_range_match_obsoletes(self): + response = self.client.get(reverse('rpms-list'), {'obsoletes': 'pkg=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 1) + + def test_filter_with_range_no_match_obsoletes(self): + response = self.client.get(reverse('rpms-list'), {'obsoletes': 'pkg=4.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 0) + + def test_filter_with_range_match_provides(self): + response = self.client.get(reverse('rpms-list'), {'provides': 'pkg=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 1) + + def test_filter_with_range_no_match_provides(self): + response = self.client.get(reverse('rpms-list'), {'provides': 'pkg=4.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 0) + + def test_filter_with_range_match_suggests(self): + response = self.client.get(reverse('rpms-list'), {'suggests': 'pkg=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 1) + + def test_filter_with_range_no_match_suggests(self): + response = self.client.get(reverse('rpms-list'), {'suggests': 'pkg=4.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 0) + + def test_filter_with_range_match_recommends(self): + response = self.client.get(reverse('rpms-list'), {'recommends': 'pkg=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 1) + + def test_filter_with_range_no_match_recommends(self): + response = self.client.get(reverse('rpms-list'), {'recommends': 'pkg=4.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 0) + + def test_filter_with_range_match_conflicts(self): + response = self.client.get(reverse('rpms-list'), {'conflicts': 'pkg=2.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 1) + + def test_filter_with_range_no_match_conflicts(self): + response = self.client.get(reverse('rpms-list'), {'conflicts': 'pkg=4.0'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 0) + + +class RPMDepsAPITestCase(TestCaseWithChangeSetMixin, APITestCase): + fixtures = [ + 'pdc/apps/common/fixtures/test/sigkey.json', + 'pdc/apps/release/fixtures/tests/release.json', + 'pdc/apps/package/fixtures/test/rpm.json', + 'pdc/apps/compose/fixtures/tests/compose.json', + 'pdc/apps/compose/fixtures/tests/compose_composerpm.json', + 'pdc/apps/compose/fixtures/tests/variant_arch.json', + 'pdc/apps/compose/fixtures/tests/variant.json' + ] + + def setUp(self): + self.maxDiff = None + + def _create_deps(self): + models.Dependency.objects.create(type=models.Dependency.SUGGESTS, + name='suggested', rpm_id=1) + models.Dependency.objects.create(type=models.Dependency.CONFLICTS, + name='conflicting', rpm_id=1) + + def test_create_rpm_with_deps(self): + data = {'name': 'fake_bash', 'version': '1.2.3', 'epoch': 0, + 'release': '4.b1', 'arch': 'x86_64', 'srpm_name': 'bash', + 'filename': 'bash-1.2.3-4.b1.x86_64.rpm', + 'linked_releases': [], 'srpm_nevra': 'fake_bash-0:1.2.3-4.b1.src', + 'dependencies': {'requires': ['required-package'], + 'obsoletes': ['obsolete-package'], + 'suggests': ['suggested-package >= 1.0.0'], + 'recommends': ['recommended = 0.1.0'], + 'provides': ['/bin/bash', '/usr/bin/whatever'], + 'conflicts': ['nothing']}} + response = self.client.post(reverse('rpms-list'), data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + response.data.pop('id') + data.update({'linked_composes': []}) + self.assertDictEqual(dict(response.data), data) + self.assertEqual(7, models.Dependency.objects.count()) + with_version = models.Dependency.objects.get(name='recommended') + self.assertEqual(with_version.comparison, '=') + self.assertEqual(with_version.version, '0.1.0') + self.assertNumChanges([1]) + + def test_put_to_rpm_with_none(self): + data = { + 'name': 'bash', + 'epoch': 0, + 'version': '1.2.3', + 'release': '4.b1', + 'arch': 'x86_64', + 'srpm_name': 'bash', + 'srpm_nevra': 'bash-0:1.2.3-4.b1.src', + 'filename': 'bash-1.2.3-4.b1.x86_64.rpm', + 'dependencies': { + 'requires': ['required-package'] + } + } + response = self.client.put(reverse('rpms-detail', args=[1]), data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, models.Dependency.objects.count()) + dep = models.Dependency.objects.first() + self.assertIsNone(dep.comparison) + self.assertIsNone(dep.version) + self.assertEqual(dep.rpm.pk, 1) + self.assertNumChanges([1]) + + def test_put_to_overwrite_existing(self): + models.Dependency.objects.create(type=models.Dependency.SUGGESTS, + name='suggested', rpm_id=1) + models.Dependency.objects.create(type=models.Dependency.CONFLICTS, + name='conflicting', rpm_id=1) + data = {'name': 'bash', + 'epoch': 0, + 'version': '1.2.3', + 'release': '4.b1', + 'arch': 'x86_64', + 'srpm_name': 'bash', + 'srpm_nevra': 'bash-0:1.2.3-4.b1.src', + 'filename': 'bash-1.2.3-4.b1.x86_64.rpm', + 'dependencies': {'requires': ['required-package']}} + response = self.client.put(reverse('rpms-detail', args=[1]), data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, models.Dependency.objects.count()) + dep = models.Dependency.objects.first() + self.assertIsNone(dep.comparison) + self.assertIsNone(dep.version) + self.assertEqual(dep.rpm.pk, 1) + self.assertEqual(dep.name, 'required-package') + self.assertEqual(dep.type, models.Dependency.REQUIRES) + self.assertNumChanges([1]) + + def test_patch_to_rpm_with_none(self): + data = {'dependencies': {'requires': ['required-package']}} + response = self.client.patch(reverse('rpms-detail', args=[1]), data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, models.Dependency.objects.count()) + dep = models.Dependency.objects.first() + self.assertIsNone(dep.comparison) + self.assertIsNone(dep.version) + self.assertEqual(dep.rpm.pk, 1) + self.assertEqual(dep.name, 'required-package') + self.assertEqual(dep.type, models.Dependency.REQUIRES) + self.assertNumChanges([1]) + + def test_patch_to_overwrite_existing(self): + self._create_deps() + data = {'dependencies': {'requires': ['required-package']}} + response = self.client.patch(reverse('rpms-detail', args=[1]), data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, models.Dependency.objects.count()) + dep = models.Dependency.objects.first() + self.assertIsNone(dep.comparison) + self.assertIsNone(dep.version) + self.assertEqual(dep.rpm.pk, 1) + self.assertEqual(dep.name, 'required-package') + self.assertEqual(dep.type, models.Dependency.REQUIRES) + self.assertNumChanges([1]) + + def test_put_to_remove(self): + self._create_deps() + data = {'name': 'bash', + 'epoch': 0, + 'version': '1.2.3', + 'release': '4.b1', + 'arch': 'x86_64', + 'srpm_name': 'bash', + 'srpm_nevra': 'bash-0:1.2.3-4.b1.src', + 'filename': 'bash-1.2.3-4.b1.x86_64.rpm', + 'dependencies': {}} + response = self.client.patch(reverse('rpms-detail', args=[1]), data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertNumChanges([1]) + self.assertEqual(0, models.Dependency.objects.count()) + + def test_patch_to_remove(self): + self._create_deps() + data = {'dependencies': {}} + response = self.client.patch(reverse('rpms-detail', args=[1]), data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertNumChanges([1]) + self.assertEqual(0, models.Dependency.objects.count()) + + def test_bad_dependency_format(self): + data = {'dependencies': {'recommends': ['foo bar']}} + response = self.client.patch(reverse('rpms-detail', args=[1]), data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertNumChanges([]) + + def test_bad_dependency_type(self): + data = {'dependencies': {'wants': ['icecream']}} + response = self.client.patch(reverse('rpms-detail', args=[1]), data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertNumChanges([]) + + def test_deps_are_not_list(self): + data = {'dependencies': {'suggests': 'pony'}} + response = self.client.patch(reverse('rpms-detail', args=[1]), data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertNumChanges([]) + + def test_deps_with_too_many_lists(self): + data = {'dependencies': {'suggests': [['pony']]}} + response = self.client.patch(reverse('rpms-detail', args=[1]), data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertNumChanges([]) + + def test_patch_without_deps_does_not_delete_existing(self): + self._create_deps() + data = {'name': 'new_name'} + response = self.client.patch(reverse('rpms-detail', args=[1]), data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertNumChanges([1]) + self.assertEqual(2, models.Dependency.objects.count()) + + def test_put_without_deps_deletes_existing(self): + self._create_deps() + data = {'name': 'new-name', + 'epoch': 0, + 'version': '1.2.3', + 'release': '4.b1', + 'arch': 'x86_64', + 'srpm_name': 'bash', + 'srpm_nevra': 'bash-0:1.2.3-4.b1.src', + 'filename': 'bash-1.2.3-4.b1.x86_64.rpm'} + response = self.client.put(reverse('rpms-detail', args=[1]), data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertNumChanges([1]) + self.assertEqual(0, models.Dependency.objects.count()) + + def test_has_no_deps_filter(self): + self._create_deps() + response = self.client.get(reverse('rpms-list'), {'has_no_deps': 'true'}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 2) + response = self.client.get(reverse('rpms-list'), {'has_no_deps': 'false'}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 1) + + class RPMAPIRESTTestCase(TestCaseWithChangeSetMixin, APITestCase): fixtures = [ 'pdc/apps/common/fixtures/test/sigkey.json', @@ -91,6 +741,10 @@ class RPMAPIRESTTestCase(TestCaseWithChangeSetMixin, APITestCase): 'pdc/apps/compose/fixtures/tests/variant.json' ] + def setUp(self): + self.empty_deps = {'conflicts': [], 'obsoletes': [], 'provides': [], + 'recommends': [], 'requires': [], 'suggests': []} + def test_query_all_rpms(self): url = reverse('rpms-list') response = self.client.get(url, format='json') @@ -177,10 +831,11 @@ def test_query_with_only_key(self): def test_retrieve_rpm(self): url = reverse('rpms-detail', args=[1]) response = self.client.get(url, format='json') - expect_data = {"id": 1, "name": "bash", "version": "1.2.3", "epoch": 0, "release": "4.b1", "arch": "x86_64", + expect_data = {"id": 1, "name": "bash", "version": "1.2.3", "epoch": 0, "release": "4.b1", + "arch": "x86_64", "srpm_name": "bash", "srpm_nevra": "bash-0:1.2.3-4.b1.src", "filename": "bash-1.2.3-4.b1.x86_64.rpm", "linked_releases": [], - "linked_composes": ["compose-1"]} + "linked_composes": ["compose-1"], "dependencies": self.empty_deps} self.assertEqual(response.data, expect_data) def test_retrieve_rpm_should_not_have_duplicated_composes(self): @@ -198,7 +853,8 @@ def test_create_rpm(self): expected_response_data = {"id": 4, 'linked_composes': [], "name": "fake_bash", "version": "1.2.3", "epoch": 0, "release": "4.b1", "arch": "x86_64", "srpm_name": "bash", "filename": "bash-1.2.3-4.b1.x86_64.rpm", - "linked_releases": ['release-1.0'], "srpm_nevra": "fake_bash-0:1.2.3-4.b1.src"} + "linked_releases": ['release-1.0'], "srpm_nevra": "fake_bash-0:1.2.3-4.b1.src", + "dependencies": self.empty_deps} self.assertEqual(response.data, expected_response_data) self.assertNumChanges([1]) @@ -256,8 +912,8 @@ def test_update_rpm(self): url = reverse('rpms-detail', args=[1]) response = self.client.put(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) - data.update({'id': 1, 'linked_composes': [u'compose-1']}) - self.assertEqual(response.data, data) + data.update({'id': 1, 'linked_composes': [u'compose-1'], 'dependencies': self.empty_deps}) + self.assertDictEqual(dict(response.data), data) self.assertNumChanges([1]) def test_update_rpm_with_linked_compose_should_read_only(self): diff --git a/pdc/apps/package/views.py b/pdc/apps/package/views.py index 162ead33..efd9b0ee 100644 --- a/pdc/apps/package/views.py +++ b/pdc/apps/package/views.py @@ -34,6 +34,26 @@ def list(self, *args, **kwargs): %(FILTERS)s + If the `has_no_deps` filter is used, the output will only contain RPMs + which have some or do not have any dependencies. + + All the dependency filters use the same data format. + + The simpler option is just name of the dependency. In that case it will + filter RPMs that depend on that given name. + + The other option is an expression `NAME OP VERSION`. This will filter + all RPMs that have a dependency on `NAME` such that adding this + constraint will not make the package dependencies inconsistent. + + For example filtering by `python=2.7.0` would include packages with + dependency on `python=2.7.0`, `python>=2.6.0`, `python<3.0.0`, but + exclude `python=2.6.0`. Filtering by `python<3.0.0` would include + packages with `python>2.7.0`, `python=2.6.0`, `python<3.3.0`, but + exclude `python>3.1.0` or `python>3.0.0 && python <3.3.0`. + + Only single filter for each dependency type is allowed. + __Response__: a paged list of following objects %(SERIALIZER)s @@ -55,6 +75,12 @@ def create(self, request, *args, **kwargs): If `filename` is not specified, it will default to a name created from *NEVRA*. + The format of each dependency is either just name of the package that + the new RPM depends on, or it can have the format `NAME OP VERSION`, + where `OP` can be any comparison operator. Recognized dependency types + are *provides*, *requires*, *obsoletes*, *conflicts*, *suggests* and + *recommends* + __Response__: %(SERIALIZER)s @@ -84,6 +110,12 @@ def update(self, request, *args, **kwargs): %(WRITABLE_SERIALIZER)s + If the `dependencies` key is omitted on `PATCH` request, they will not + be changed. On `PUT` request, they will be completely removed. When a + value is specified, it completely replaces existing dependencies. + + The format of the dependencies themselves is same as for create. + __Response__: %(SERIALIZER)s