Repository: fedora-infra/bodhi

Issue:
bodhi.server.models.models.Update.stable_karma should disallow NULL values
Many of our columns have this problem, but the stable_karma in particular should disallow NULL values and require the column to always be explicitly True or False. Since it allows NULL, there are all kinds of checks in the code that look like `stable_karma not in (0, None)`. As it is, there is possibility of new code forgetting about the two possible "Falsy" values. It's better for the database to be opinionated.

Of course, we will also need to write a migration to set stable_karma to False where it is None and then convert the column to be non-nullable. Due to the migration, I am not marking this as an easy fix (even though it probably actually is).



Discussion:
Based on:
https://github.com/fedora-infra/bodhi/blob/a1a3cdd4089acd3e2477d5375f01e211585a5711/bodhi/server/schemas.py#L234
Can we drop False/True/None values and assume that db stable_karma value should be INT and not None?
@sebwoj yeah that's the proposal - we should enforce ```stable_karma``` being an ```int``` everywhere.

Resolving patch:
diff --git a/bodhi/server/migrations/versions/bdf0e37ab793_disallow_null_values_in_stable_karma_.py b/bodhi/server/migrations/versions/bdf0e37ab793_disallow_null_values_in_stable_karma_.py
new file mode 100644
--- /dev/null
+++ b/bodhi/server/migrations/versions/bdf0e37ab793_disallow_null_values_in_stable_karma_.py
@@ -0,0 +1,58 @@
+# Copyright (c) 2019 Sebastian Wojciechowski.
+#
+# This file is part of Bodhi.
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+"""
+Disallow null values in stable_karma and unstable_karma.
+
+Revision ID: bdf0e37ab793
+Revises: 6b3eb9ae2b87
+Create Date: 2019-02-08 20:19:06.389860
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'bdf0e37ab793'
+down_revision = '6b3eb9ae2b87'
+
+
+def upgrade():
+    """
+    Disallow NULL values in stable_karma and unstable_karma.
+
+    Find all records with stable_karma and unstable_karma set to NULL and change them to
+    be 3(-3), then disallow further NULL values.
+    """
+    # Build a fake mini version of the updates table so we can form an UPDATE statement.
+    updates = sa.sql.table('updates',
+                           sa.sql.column('stable_karma', sa.Integer),
+                           sa.sql.column('unstable_karma', sa.Integer))
+    # Set records with NULL stable_karma and unstable_karma to 3 and -3.
+    op.execute(updates.update().where(updates.c.stable_karma.is_(None)).values({'stable_karma': 3}))
+    op.execute(
+        updates.update().where(updates.c.unstable_karma.is_(None)).values({'unstable_karma': -3})
+    )
+    # Finally, disallow new NULL values.
+    op.alter_column('updates', 'stable_karma', existing_type=sa.INTEGER(), nullable=False)
+    op.alter_column('updates', 'unstable_karma', existing_type=sa.INTEGER(), nullable=False)
+
+
+def downgrade():
+    """Re-allow NULL values."""
+    op.alter_column('updates', 'stable_karma', existing_type=sa.INTEGER(), nullable=True)
+    op.alter_column('updates', 'unstable_karma', existing_type=sa.INTEGER(), nullable=True)
diff --git a/bodhi/server/models.py b/bodhi/server/models.py
--- a/bodhi/server/models.py
+++ b/bodhi/server/models.py
@@ -1637,8 +1637,8 @@ class Update(Base):
     title = Column(UnicodeText, unique=True, default=None, index=True)
 
     autokarma = Column(Boolean, default=True, nullable=False)
-    stable_karma = Column(Integer, nullable=True)
-    unstable_karma = Column(Integer, nullable=True)
+    stable_karma = Column(Integer, nullable=False)
+    unstable_karma = Column(Integer, nullable=False)
     requirements = Column(UnicodeText)
     require_bugs = Column(Boolean, default=False)
     require_testcases = Column(Boolean, default=False)
@@ -2535,8 +2535,7 @@ def set_request(self, db, action, username):
         flash_notes = ''
         if action == UpdateRequest.stable and not self.critpath:
             # Check if we've met the karma requirements
-            if (self.stable_karma not in (None, 0) and self.karma >= self.stable_karma) \
-                    or self.critpath_approved:
+            if self.karma >= self.stable_karma or self.critpath_approved:
                 log.debug('%s meets stable karma requirements' % self.title)
             else:
                 # If we haven't met the stable karma requirements, check if it
@@ -2876,7 +2875,7 @@ def obsolete_if_unstable(self, db):
             db (sqlalchemy.orm.session.Session): A database session.
         """
         if self.autokarma and self.status is UpdateStatus.pending \
-                and self.request is UpdateRequest.testing and self.unstable_karma not in (0, None) \
+                and self.request is UpdateRequest.testing\
                 and self.karma <= self.unstable_karma:
             log.info("%s has reached unstable karma thresholds" % self.title)
             self.obsolete(db)
@@ -3366,8 +3365,7 @@ def meets_testing_requirements(self):
 
         # non-autokarma updates have met the testing requirements if they've reached the karma
         # threshold.
-        if not self.autokarma and self.stable_karma not in (0, None)\
-                and self.karma >= self.stable_karma:
+        if not self.autokarma and self.karma >= self.stable_karma:
             return True
 
         # Any update that reaches num_days has met the testing requirements.


Test patch:
diff --git a/bodhi/server/scripts/approve_testing.py b/bodhi/server/scripts/approve_testing.py
--- a/bodhi/server/scripts/approve_testing.py
+++ b/bodhi/server/scripts/approve_testing.py
@@ -1,4 +1,4 @@
-# Copyright © 2013-2017 Red Hat, Inc. and others.
+# Copyright © 2013-2019 Red Hat, Inc. and others.
 #
 # This file is part of Bodhi.
 #
@@ -85,7 +85,7 @@ def main(argv=sys.argv):
                 continue
 
             # Approval message when testing based on karma threshold
-            if update.stable_karma not in (0, None) and update.karma >= update.stable_karma \
+            if update.karma >= update.stable_karma \
                     and not update.autokarma and update.meets_testing_requirements:
                 print('%s now reaches stable karma threshold' % update.title)
                 text = config.get('testing_approval_msg_based_on_karma')
diff --git a/bodhi/tests/client/test_bindings.py b/bodhi/tests/client/test_bindings.py
--- a/bodhi/tests/client/test_bindings.py
+++ b/bodhi/tests/client/test_bindings.py
@@ -1,5 +1,5 @@
 # -*- coding: utf-8 -*-
-# Copyright 2008-2018 Red Hat, Inc. and others.
+# Copyright 2008-2019 Red Hat, Inc. and others.
 #
 # This file is part of Bodhi.
 #
@@ -1234,7 +1234,7 @@ def test_severity(self):
 
     def test_with_autokarma_set(self):
         """
-        Ensure correct operation when autokarma is True, and stable/unstable karmas are set.
+        Ensure correct operation when autokarma is True..
         """
         client = bindings.BodhiClient(username='some_user', password='s3kr3t')
         client.base_url = 'http://example.com/tests/'
@@ -1245,20 +1245,18 @@ def test_with_autokarma_set(self):
 
     def test_with_autokarma_unset(self):
         """
-        Ensure correct operation when autokarma is Fale, and stable/unstable karmas are None.
+        Ensure correct operation when autokarma is False.
         """
         client = bindings.BodhiClient(username='some_user', password='s3kr3t')
         client.base_url = 'http://example.com/tests/'
         update = copy.deepcopy(client_test_data.EXAMPLE_UPDATE_MUNCH)
-        # Set the update's autokarma and thresholds to False/None.
+        # Set the update's autokarma and to False.
         update.autokarma = False
-        update.unstable_karma = None
-        update.stable_karma = None
 
         text = client.update_str(update)
 
         expected_output = client_test_data.EXPECTED_UPDATE_OUTPUT.replace(
-            'Autokarma: True  [-3, 3]', 'Autokarma: False  [None, None]')
+            'Autokarma: True  [-3, 3]', 'Autokarma: False  [-3, 3]')
         self.assertTrue(compare_output(text, expected_output))
 
     def test_update_as_string(self):
diff --git a/bodhi/tests/server/consumers/test_composer.py b/bodhi/tests/server/consumers/test_composer.py
--- a/bodhi/tests/server/consumers/test_composer.py
+++ b/bodhi/tests/server/consumers/test_composer.py
@@ -539,7 +539,8 @@ def test_tag_ordering(self, publish, *args):
             session.add(build)
             update = Update(
                 title=otherbuild, builds=[build], type=UpdateType.bugfix,
-                request=UpdateRequest.testing, notes=u'second update', user=firstupdate.user)
+                request=UpdateRequest.testing, notes=u'second update', user=firstupdate.user,
+                stable_karma=3, unstable_karma=-3)
             update.release = firstupdate.release
             session.add(update)
             session.flush()
@@ -928,7 +929,9 @@ def test_security_update_priority(self, publish, *args):
                 status=UpdateStatus.testing,
                 request=UpdateRequest.stable,
                 notes=u'Useful details!', release=release,
-                test_gating_status=TestGatingStatus.passed)
+                test_gating_status=TestGatingStatus.passed,
+                stable_karma=3,
+                unstable_karma=-3)
             update.type = UpdateType.security
             db.add(update)
 
@@ -1018,7 +1021,9 @@ def test_security_update_priority_testing(self, publish, *args):
                 status=UpdateStatus.testing,
                 request=UpdateRequest.stable,
                 notes=u'Useful details!', release=release,
-                test_gating_status=TestGatingStatus.passed)
+                test_gating_status=TestGatingStatus.passed,
+                stable_karma=3,
+                unstable_karma=-3)
             update.type = UpdateType.enhancement
             db.add(update)
 
@@ -1100,7 +1105,9 @@ def test_security_updates_parallel(self, publish, *args):
                 status=UpdateStatus.testing,
                 request=UpdateRequest.stable,
                 notes=u'Useful details!', release=release,
-                test_gating_status=TestGatingStatus.passed)
+                test_gating_status=TestGatingStatus.passed,
+                stable_karma=3,
+                unstable_karma=-3)
             update.type = UpdateType.security
             db.add(update)
 
@@ -1524,7 +1531,9 @@ def test_compose_module(self, publish, *args):
                 status=UpdateStatus.testing,
                 request=UpdateRequest.stable,
                 notes=u'Useful details!', release=release,
-                test_gating_status=TestGatingStatus.passed)
+                test_gating_status=TestGatingStatus.passed,
+                stable_karma=3,
+                unstable_karma=-3)
             update.type = UpdateType.security
             db.add(update)
 
@@ -2202,7 +2211,8 @@ def test_obsolete_older_updates(self, publish, *args):
             session.add(build)
             update = Update(
                 title=otherbuild, builds=[build], type=UpdateType.bugfix,
-                request=UpdateRequest.testing, notes=u'second update', user=oldupdate.user)
+                request=UpdateRequest.testing, notes=u'second update', user=oldupdate.user,
+                stable_karma=3, unstable_karma=-3)
             update.release = oldupdate.release
             session.add(update)
             session.flush()
@@ -2318,7 +2328,9 @@ def setUp(self):
             status=UpdateStatus.pending,
             request=UpdateRequest.testing,
             notes=u'Neat I can compose containers now', release=release,
-            test_gating_status=TestGatingStatus.passed)
+            test_gating_status=TestGatingStatus.passed,
+            stable_karma=3,
+            unstable_karma=-3)
         update.type = UpdateType.bugfix
         self.db.add(update)
         # Wipe out the tag cache so it picks up our new release
@@ -2484,7 +2496,9 @@ def setUp(self):
             status=UpdateStatus.pending,
             request=UpdateRequest.testing,
             notes=u'Neat I can compose flatpaks now', release=release,
-            test_gating_status=TestGatingStatus.passed)
+            test_gating_status=TestGatingStatus.passed,
+            stable_karma=3,
+            unstable_karma=-3)
         update.type = UpdateType.bugfix
         self.db.add(update)
         # Wipe out the tag cache so it picks up our new release
diff --git a/bodhi/tests/server/scripts/test_approve_testing.py b/bodhi/tests/server/scripts/test_approve_testing.py
--- a/bodhi/tests/server/scripts/test_approve_testing.py
+++ b/bodhi/tests/server/scripts/test_approve_testing.py
@@ -1,4 +1,4 @@
-# Copyright © 2016-2018 Red Hat, Inc. and others.
+# Copyright © 2016-2019 Red Hat, Inc. and others.
 #
 # This file is part of Bodhi.
 #
@@ -341,60 +341,6 @@ def test_non_autokarma_critpath_update_meeting_time_requirements_gets_one_commen
         self.assertEqual(update.release.mandatory_days_in_testing, 7)
         self.assertEqual(update.mandatory_days_in_testing, 14)
 
-    def test_non_autokarma_update_with_stable_karma_0(self):
-        """
-        A non-autokarma update with a stable_karma set to 0 should not get comments from Bodhi.
-        """
-        update = self.db.query(models.Update).all()[0]
-        update.autokarma = False
-        update.request = None
-        update.stable_karma = 0
-        update.status = models.UpdateStatus.testing
-        update.comment(self.db, u'testing', author=u'hunter2', anonymous=False, karma=1)
-        with fml_testing.mock_sends(api.Message):
-            self.db.commit()
-
-        with patch('bodhi.server.scripts.approve_testing.initialize_db'):
-            with patch('bodhi.server.scripts.approve_testing.get_appsettings', return_value=''):
-                with fml_testing.mock_sends():
-                    approve_testing.main(['nosetests', 'some_config.ini'])
-
-        # The bodhi user shouldn't exist, since it shouldn't have made any comments
-        self.assertEqual(self.db.query(models.User).filter_by(name=u'bodhi').count(), 0)
-        # There are three comments, but none from the non-existing bodhi user.
-        self.assertEqual(self.db.query(models.Comment).count(), 3)
-        usernames = [
-            c.user.name
-            for c in self.db.query(models.Comment).order_by(models.Comment.timestamp).all()]
-        self.assertEqual(usernames, [u'guest', u'anonymous', u'hunter2'])
-
-    def test_non_autokarma_update_with_stable_karma_None(self):
-        """
-        A non-autokarma update with a stable_karma set to None should not get comments from Bodhi.
-        """
-        update = self.db.query(models.Update).all()[0]
-        update.autokarma = False
-        update.request = None
-        update.stable_karma = None
-        update.status = models.UpdateStatus.testing
-        update.comment(self.db, u'testing', author=u'hunter2', anonymous=False, karma=1)
-        with fml_testing.mock_sends(api.Message):
-            self.db.commit()
-
-        with patch('bodhi.server.scripts.approve_testing.initialize_db'):
-            with patch('bodhi.server.scripts.approve_testing.get_appsettings', return_value=''):
-                with fml_testing.mock_sends():
-                    approve_testing.main(['nosetests', 'some_config.ini'])
-
-        # The bodhi user shouldn't exist, since it shouldn't have made any comments
-        self.assertEqual(self.db.query(models.User).filter_by(name=u'bodhi').count(), 0)
-        # There are three comments, but none from the non-existing bodhi user.
-        self.assertEqual(self.db.query(models.Comment).count(), 3)
-        usernames = [
-            c.user.name
-            for c in self.db.query(models.Comment).order_by(models.Comment.timestamp).all()]
-        self.assertEqual(usernames, [u'guest', u'anonymous', u'hunter2'])
-
     def test_non_autokarma_update_with_unmet_karma_requirement(self):
         """
         A non-autokarma update without enough karma should not get comments from Bodhi.
diff --git a/bodhi/tests/server/services/test_updates.py b/bodhi/tests/server/services/test_updates.py
--- a/bodhi/tests/server/services/test_updates.py
+++ b/bodhi/tests/server/services/test_updates.py
@@ -4942,7 +4942,8 @@ def test_submit_older_build_to_stable(self, publish, *args):
         self.db.add(build)
         update = Update(title=oldbuild, builds=[build], type=UpdateType.bugfix,
                         request=UpdateRequest.testing, notes=u'second update',
-                        user=update.user, release=update.release)
+                        user=update.user, release=update.release,
+                        stable_karma=3, unstable_karma=-3)
         update.comment(self.db, u"foo1", 1, u'foo1')
         update.comment(self.db, u"foo2", 1, u'foo2')
         update.comment(self.db, u"foo3", 1, u'foo3')
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
@@ -3169,38 +3169,6 @@ def test_meets_testing_requirements_with_non_autokarma_update_reaching_stable_ka
         # meets_testing_requirement() should return True since the karma threshold has been reached
         self.assertEqual(self.obj.meets_testing_requirements, True)
 
-    def test_meets_testing_requirements_with_non_autokarma_update_with_stable_karma_0(self):
-        """
-        Assert that meets_testing_requirements() correctly returns False for non-autokarma updates
-        that haven't reached the days in testing but have stable_karma set to 0 (indicating that the
-        update must stay in testing for the full amount of time).
-        """
-        self.obj.autokarma = False
-        self.obj.status = UpdateStatus.testing
-        self.obj.stable_karma = 0
-        # Now let's add some karma to get it above stable_karma, which should not be counted as
-        # meeting the requirements.
-        self.obj.comment(self.db, u'testing', author=u'hunter2', anonymous=False, karma=1)
-
-        # meets_testing_requirement() should return False since the stable_karma threshold is 0.
-        self.assertEqual(self.obj.meets_testing_requirements, False)
-
-    def test_meets_testing_requirements_with_non_autokarma_update_with_stable_karma_None(self):
-        """
-        Assert that meets_testing_requirements() correctly returns False for non-autokarma updates
-        that haven't reached the days in testing but have stable_karma set to None (indicating that
-        the update must stay in testing for the full amount of time).
-        """
-        self.obj.autokarma = False
-        self.obj.status = UpdateStatus.testing
-        self.obj.stable_karma = None
-        # Now let's add some karma to get it above stable_karma, which should not be counted as
-        # meeting the requirements.
-        self.obj.comment(self.db, u'testing', author=u'hunter2', anonymous=False, karma=1)
-
-        # meets_testing_requirement() should return False since the stable_karma threshold is None.
-        self.assertEqual(self.obj.meets_testing_requirements, False)
-
     def test_meets_testing_requirements_critpath_negative_karma(self):
         """
         Assert that meets_testing_requirements() correctly returns False for critpath updates
@@ -3693,7 +3661,9 @@ def test_validate_release_failure(self):
             request=UpdateRequest.stable,
             type=UpdateType.enhancement,
             notes=u'Useful details!',
-            test_gating_status=TestGatingStatus.passed)
+            test_gating_status=TestGatingStatus.passed,
+            stable_karma=3,
+            unstable_karma=-3)
         update.release = release
         self.db.add(update)
         self.db.flush()