Repository: fedora-infra/bodhi

Issue:
We need to validate that RpmBuilds are only associated with RpmPackages and vice-versa
This is also true for ModuleBuilds and ModulePackages, though we can solve that one in #1324.


Discussion:

Resolving patch:
diff --git a/alembic/versions/b01a62d98aa4_convert_package_and_build_type_columns_.py b/alembic/versions/b01a62d98aa4_convert_package_and_build_type_columns_.py
new file mode 100644
--- /dev/null
+++ b/alembic/versions/b01a62d98aa4_convert_package_and_build_type_columns_.py
@@ -0,0 +1,87 @@
+"""Convert type columns in the packages and builds tables to enums.
+
+Revision ID: b01a62d98aa4
+Revises: c6a5e2849ca4
+Create Date: 2017-05-02 18:39:32.400198
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'b01a62d98aa4'
+down_revision = 'c6a5e2849ca4'
+
+builds = sa.sql.table('builds', sa.sql.column('type', sa.Integer))
+packages = sa.sql.table('packages', sa.sql.column('type', sa.Integer))
+
+
+def upgrade():
+    """
+    Convert the type columns in the builds and packages tables from ints to enums.
+
+    Raises:
+        ValueError: This migration will fail if there are any non-RPM Packages or Builds found in
+            the database.
+    """
+    # Make sure we only have RPM Builds.
+    count = op.get_bind().execute(
+        sa.select([sa.func.count()]).where(builds.c.type != 1)).fetchall()[0][0]
+    if count != 0:
+        raise ValueError(
+            ('This migration is written to assume that all Builds in the database are RPMs, but '
+             'this database has non-RPM Builds. The migration will need to be refactored to '
+             'support non-RPMs.'))
+
+    # Make sure we only have RPM Packages.
+    count = op.get_bind().execute(
+        sa.select([sa.func.count()]).where(packages.c.type != 1)).fetchall()[0][0]
+    if count != 0:
+        raise ValueError(
+            ('This migration is written to assume that all Packages in the database are RPMs, but '
+             'this database has non-RPM Packages. The migration will need to be refactored to '
+             'support non-RPMs.'))
+
+    # Drop the integer-based columns
+    op.drop_column('builds', 'type')
+    op.drop_column('packages', 'type')
+
+    # Re-add the columns as the new enum type.
+    op.execute("CREATE TYPE ck_content_type AS ENUM ('base','rpm','module')")
+    op.add_column(
+        'builds',
+        sa.Column('type', sa.Enum('base', 'rpm', 'module', name='ck_content_type'),
+                  nullable=True))
+    op.add_column(
+        'packages',
+        sa.Column('type', sa.Enum('base', 'rpm', 'module', name='ck_content_type'),
+                  nullable=True))
+
+    # Now mark all builds and packages as RPMs again.
+    op.execute(builds.update().values({'type': 'rpm'}))
+    op.execute(packages.update().values({'type': 'rpm'}))
+
+    # Finally, make the columns non-nullable.
+    op.alter_column('builds', 'type', nullable=False)
+    op.alter_column('packages', 'type', nullable=False)
+
+
+def downgrade():
+    """
+    Convert the type columns in the builds and packages tables from enums to ints.
+    """
+    # Drop the enum-typed columns.
+    op.drop_column('builds', 'type')
+    op.drop_column('packages', 'type')
+    # Drop the enum too.
+    op.execute("DROP TYPE ck_content_type")
+
+    # Re-add the integer-typed columns.
+    op.add_column('packages', sa.Column('type', sa.INTEGER(), autoincrement=False, nullable=True))
+    op.add_column('builds', sa.Column('type', sa.INTEGER(), autoincrement=False, nullable=True))
+    # Set all objects to 1, which was used to encode RPMs.
+    op.execute(builds.update().values({'type': 1}))
+    op.execute(packages.update().values({'type': 1}))
+    # Finally, make the columns non-nullable.
+    op.alter_column('builds', 'type', nullable=False)
+    op.alter_column('packages', 'type', nullable=False)
diff --git a/bodhi/server/models.py b/bodhi/server/models.py
--- a/bodhi/server/models.py
+++ b/bodhi/server/models.py
@@ -76,10 +76,10 @@ def __repr__(self):
         return "<%s>" % self.name
 
     def __unicode__(self):
-        return unicode(self.description)
+        return unicode(self.value)
 
     def __json__(self, request=None):
-        return self.description
+        return self.value
 
 
 class EnumMeta(type):
@@ -307,6 +307,11 @@ def update_relationship(self, name, model, data, db):
 ##
 #  Enumerated type declarations
 ##
+class ContentType(DeclEnum):
+    base = 'base', 'Base'
+    rpm = 'rpm', 'RPM'
+    module = 'module', 'Module'
+
 
 class UpdateStatus(DeclEnum):
     pending = 'pending', 'pending'
@@ -497,7 +502,7 @@ class to create when loading rows from the database.
 
     name = Column(UnicodeText, nullable=False)
     requirements = Column(UnicodeText)
-    type = Column(Integer, nullable=False)
+    type = Column(ContentType.db_type(), nullable=False)
 
     builds = relationship('Build', backref=backref('package', lazy='joined'))
     test_cases = relationship('TestCase', backref='package')
@@ -508,7 +513,7 @@ class to create when loading rows from the database.
 
     __mapper_args__ = {
         'polymorphic_on': type,
-        'polymorphic_identity': 0,
+        'polymorphic_identity': ContentType.base,
     }
 
     __table_args__ = (
@@ -593,6 +598,31 @@ def list_categorymembers(wiki, cat_page, limit=500):
 
         log.debug('Finished querying for test cases in %s', datetime.utcnow() - start)
 
+    @validates('builds')
+    def validate_builds(self, key, build):
+        """
+        Validates builds being appended to ensure they are all the same type as the Package.
+
+        This method checks to make sure that all the builds on self.builds have their type attribute
+        equal to self.type. The goal is to make sure that Builds of a specific type are only ever
+        associated with Packages of the same type and vice-versa. For example, RpmBuilds should only
+        ever associate with RpmPackages and never with ModulePackages.
+
+        Args:
+            key (str): The field's key, which is un-used in this validator.
+            build (Build): The build object which was appended to the list
+                of builds.
+
+        Raises:
+            ValueError: If the build being appended is not the same type as the package.
+        """
+        if build.type != self.type:
+            raise ValueError(
+                (u"A {} Build cannot be associated with a {} Package. A Package's builds must be "
+                 u"the same type as the package.").format(
+                     build.type.description, self.type.description))
+        return build
+
     def __str__(self):
         x = header(self.name)
         states = {'pending': [], 'testing': [], 'stable': []}
@@ -611,11 +641,17 @@ def __str__(self):
         return x
 
 
+class ModulePackage(Package):
+    """Represents a Module package."""
+    __mapper_args__ = {
+        'polymorphic_identity': ContentType.module,
+    }
+
+
 class RpmPackage(Package):
     """Represents a RPM package."""
-
     __mapper_args__ = {
-        'polymorphic_identity': 1,
+        'polymorphic_identity': ContentType.rpm,
     }
 
 
@@ -658,10 +694,10 @@ class Build(Base):
 
     release = relationship('Release', backref='builds', lazy=False)
 
-    type = Column(Integer, nullable=False)
+    type = Column(ContentType.db_type(), nullable=False)
     __mapper_args__ = {
         'polymorphic_on': type,
-        'polymorphic_identity': 0,
+        'polymorphic_identity': ContentType.base,
     }
 
     def get_url(self):
@@ -729,6 +765,20 @@ def unpush(self, koji):
                 koji.moveBuild(tag, release.candidate_tag, self.nvr)
 
 
+class ModuleBuild(Build):
+    """
+    Represents a Module build.
+
+    Note that this model uses single-table inheritance with its Build superclass.
+
+    Attributes:
+        nvr (unicode): A unique Koji identifier for the module build.
+    """
+    __mapper_args__ = {
+        'polymorphic_identity': ContentType.module,
+    }
+
+
 class RpmBuild(Build):
     """
     Represents an RPM build.
@@ -744,7 +794,7 @@ class RpmBuild(Build):
     epoch = Column(Integer, default=0)
 
     __mapper_args__ = {
-        'polymorphic_identity': 1,
+        'polymorphic_identity': ContentType.rpm,
     }
 
     @property


Test patch:
diff --git a/bodhi/tests/server/test_models.py b/bodhi/tests/server/test_models.py
--- a/bodhi/tests/server/test_models.py
+++ b/bodhi/tests/server/test_models.py
@@ -170,11 +170,108 @@ def test_two_package_same_type(self):
         self.assertRaises(IntegrityError, self.db.flush)
 
 
-class TestPackage(ModelTest):
+class TestModulePackage(ModelTest, unittest.TestCase):
+    """Unit test case for the ``ModulePackage`` model."""
+    klass = model.ModulePackage
+    attrs = dict(name=u"TurboGears")
+
+    def do_get_dependencies(self):
+        return dict(
+            committers=[model.User(name=u'lmacken')]
+        )
+
+    def setUp(self):
+        super(TestModulePackage, self).setUp()
+        self.package = model.ModulePackage(name=u'the-greatest-package')
+        self.db.add(self.package)
+
+    def test_adding_rpmbuild(self):
+        """Assert that validation fails when adding a RpmBuild."""
+        build1 = model.ModuleBuild(nvr=u'the-greatest-package-1.0.0-fc17.1')
+        build2 = model.RpmBuild(nvr=u'the-greatest-package-1.1.0-fc17.1')
+        self.package.builds.append(build1)
+
+        with self.assertRaises(ValueError) as exc_context:
+            self.package.builds.append(build2)
+
+        self.assertEqual(
+            unicode(exc_context.exception),
+            ("A RPM Build cannot be associated with a Module Package. A Package's "
+             "builds must be the same type as the package."))
+
+    def test_adding_list_of_module_and_rpmbuild(self):
+        """Assert that validation fails when adding a ModuleBuild and RpmBuild via a list."""
+        build1 = model.ModuleBuild(nvr=u'the-greatest-package-1.0.0-fc17.1')
+        build2 = model.RpmBuild(nvr=u'the-greatest-package-1.1.0-fc17.1')
+
+        with self.assertRaises(ValueError) as exc_context:
+            self.package.builds = [build1, build2]
+
+        self.assertEqual(
+            unicode(exc_context.exception),
+            ("A RPM Build cannot be associated with a Module Package. A Package's "
+             "builds must be the same type as the package."))
+
+    def test_backref_no_builds(self):
+        """Assert that a ModuleBuild can be appended via a backref."""
+        build = model.ModuleBuild(nvr=u'the-greatest-package-1.0.0-fc17.1')
+        build.package = self.package
+
+        # This should not raise any Exception.
+        self.db.flush()
+
+    def test_backref_rpmbuild(self):
+        """Assert that adding an RpmBuild via backref fails validation."""
+        build1 = model.ModuleBuild(nvr=u'the-greatest-package-1.0.0-fc17.1')
+        build2 = model.RpmBuild(nvr=u'the-greatest-package-1.1.0-fc17.1')
+        build1.package = self.package
+
+        with self.assertRaises(ValueError) as exc_context:
+            build2.package = self.package
+
+        self.assertEqual(
+            unicode(exc_context.exception),
+            ("A RPM Build cannot be associated with a Module Package. A Package's "
+             "builds must be the same type as the package."))
+
+    def test_backref_second_modulebuild(self):
+        """Assert that two ModuleBuilds can be appended via backrefs."""
+        build1 = model.ModuleBuild(nvr=u'the-greatest-package-1.0.0-fc17.1')
+        build2 = model.ModuleBuild(nvr=u'the-greatest-package-1.1.0-fc17.1')
+        build1.package = self.package
+        build2.package = self.package
+
+        # This should not raise any Exception.
+        self.db.flush()
+
+    def test_no_builds(self):
+        """Assert that one ModuleBuild can be appended."""
+        build = model.ModuleBuild(nvr=u'the-greatest-package-1.0.0-fc17.1')
+        self.package.builds.append(build)
+
+        # This should not raise any Exception.
+        self.db.flush()
+
+    def test_same_build_types(self):
+        """Assert that two builds of the module type can be added and that validation passes."""
+        build1 = model.ModuleBuild(nvr=u'the-greatest-package-1.0.0-fc17.1')
+        build2 = model.ModuleBuild(nvr=u'the-greatest-package-1.1.0-fc17.1')
+        self.package.builds += [build1, build2]
+
+        # This should not raise any Exception.
+        self.db.flush()
+
+
+class TestRpmPackage(ModelTest, unittest.TestCase):
     """Unit test case for the ``Package`` model."""
-    klass = model.Package
+    klass = model.RpmPackage
     attrs = dict(name=u"TurboGears")
 
+    def setUp(self):
+        super(TestRpmPackage, self).setUp()
+        self.package = model.RpmPackage(name=u'the-greatest-package')
+        self.db.add(self.package)
+
     def do_get_dependencies(self):
         return dict(
             committers=[model.User(name=u'lmacken')]
@@ -194,13 +291,76 @@ def test_wiki_test_cases(self):
         # Now, our actual test.
         with mock.patch('bodhi.server.models.MediaWiki', MockWiki(response)):
             config['query_wiki_test_cases'] = True
-            pkg = model.Package(name=u'gnome-shell')
+            pkg = model.RpmPackage(name=u'gnome-shell')
             pkg.fetch_test_cases(self.db)
             assert pkg.test_cases
 
+    def test_adding_modulebuild(self):
+        """Assert that validation fails when adding a ModuleBuild."""
+        build1 = model.RpmBuild(nvr=u'the-greatest-package-1.0.0-fc17.1')
+        build2 = model.ModuleBuild(nvr=u'the-greatest-package-1.1.0-fc17.1')
+        self.package.builds.append(build1)
+
+        with self.assertRaises(ValueError) as exc_context:
+            self.package.builds.append(build2)
+
+        self.assertEqual(
+            unicode(exc_context.exception),
+            ("A Module Build cannot be associated with a RPM Package. A Package's "
+             "builds must be the same type as the package."))
+
+    def test_backref_no_builds(self):
+        """Assert that a RpmBuild can be appended via a backref."""
+        build = model.RpmBuild(nvr=u'the-greatest-package-1.0.0-fc17.1')
+        build.package = self.package
+
+        # This should not raise any Exception.
+        self.db.flush()
+
+    def test_backref_modulebuild(self):
+        """Assert that adding a ModuleBuild via backref fails validation."""
+        build1 = model.RpmBuild(nvr=u'the-greatest-package-1.0.0-fc17.1')
+        build2 = model.ModuleBuild(nvr=u'the-greatest-package-1.1.0-fc17.1')
+        build1.package = self.package
+
+        with self.assertRaises(ValueError) as exc_context:
+            build2.package = self.package
+
+        self.assertEqual(
+            unicode(exc_context.exception),
+            ("A Module Build cannot be associated with a RPM Package. A Package's "
+             "builds must be the same type as the package."))
+
+    def test_backref_second_modulebuild(self):
+        """Assert that two RpmBuilds can be appended via backrefs."""
+        build1 = model.RpmBuild(nvr=u'the-greatest-package-1.0.0-fc17.1')
+        build2 = model.RpmBuild(nvr=u'the-greatest-package-1.1.0-fc17.1')
+        build1.package = self.package
+        build2.package = self.package
+
+        # This should not raise any Exception.
+        self.db.flush()
+
     def test_committers(self):
         assert self.obj.committers[0].name == u'lmacken'
 
+    def test_no_builds(self):
+        """Assert that one RpmBuild can be appended."""
+        build = model.RpmBuild(nvr=u'the-greatest-package-1.0.0-fc17.1')
+        self.package.builds.append(build)
+
+        # This should not raise any Exception.
+        self.db.flush()
+
+    def test_same_build_types(self):
+        """Assert that two builds of the RPM type can be added and that validation passes."""
+        build1 = model.RpmBuild(nvr=u'the-greatest-package-1.0.0-fc17.1')
+        build2 = model.RpmBuild(nvr=u'the-greatest-package-1.1.0-fc17.1')
+        self.package.builds += [build1, build2]
+
+        # This should not raise any Exception.
+        self.db.flush()
+
 
 class TestRpmBuild(ModelTest):
     """Unit test case for the ``RpmBuild`` model."""
@@ -209,7 +369,7 @@ class TestRpmBuild(ModelTest):
 
     def do_get_dependencies(self):
         return dict(release=model.Release(**TestRelease.attrs),
-                    package=model.Package(**TestPackage.attrs))
+                    package=model.RpmPackage(**TestRpmPackage.attrs))
 
     def test_release_relation(self):
         eq_(self.obj.release.name, u"F11")
@@ -234,7 +394,7 @@ class TestUpdateValidateBuilds(BaseTestCase):
 
     def setUp(self):
         super(TestUpdateValidateBuilds, self).setUp()
-        self.package = model.Package(name='the-greatest-package')
+        self.package = model.RpmPackage(name='the-greatest-package')
         self.update = model.Update(
             title='The best update of all time.',
             user=model.User.query.filter_by(name=u'guest').one(),
@@ -348,7 +508,7 @@ def do_get_dependencies(self):
         release = model.Release(**TestRelease.attrs)
         return dict(
             builds=[model.RpmBuild(
-                nvr=u'TurboGears-1.0.8-3.fc11', package=model.Package(**TestPackage.attrs),
+                nvr=u'TurboGears-1.0.8-3.fc11', package=model.RpmPackage(**TestRpmPackage.attrs),
                 release=release)],
             bugs=[model.Bug(bug_id=1), model.Bug(bug_id=2)],
             cves=[model.CVE(cve_id=u'CVE-2009-0001')],
@@ -359,7 +519,7 @@ def get_update(self, name=u'TurboGears-1.0.8-3.fc11'):
         """Return an Update instance for testing."""
         attrs = self.attrs.copy()
         attrs['title'] = name
-        pkg = self.db.query(model.Package).filter_by(name=u'TurboGears').one()
+        pkg = self.db.query(model.RpmPackage).filter_by(name=u'TurboGears').one()
         rel = self.db.query(model.Release).filter_by(name=u'F11').one()
         attrs.update(dict(
             builds=[model.RpmBuild(nvr=name, package=pkg, release=rel)],
@@ -1156,6 +1316,6 @@ class TestBuildrootOverride(ModelTest):
     def do_get_dependencies(self):
         return dict(
             build=model.RpmBuild(
-                nvr=u'TurboGears-1.0.8-3.fc11', package=model.Package(**TestPackage.attrs),
+                nvr=u'TurboGears-1.0.8-3.fc11', package=model.RpmPackage(**TestRpmPackage.attrs),
                 release=model.Release(**TestRelease.attrs)),
             submitter=model.User(name=u'lmacken'))
diff --git a/bodhi/tests/server/test_notifications.py b/bodhi/tests/server/test_notifications.py
--- a/bodhi/tests/server/test_notifications.py
+++ b/bodhi/tests/server/test_notifications.py
@@ -113,7 +113,7 @@ def test_publish_sqlalchemy_object(self, mock_init):
         expected_msg = {
             u'some_package': {
                 u'name': u'so good',
-                u'type': 0,
+                u'type': 'base',
                 u'requirements': None,
                 u'stack': None,
                 u'stack_id': None,