#203 rework the Update model, add create-test-data
Merged 9 months ago by kparal. Opened 10 months ago by kparal.

@@ -0,0 +1,87 @@ 

+ """Rework Update model

+ 

+ Revision ID: 8ec130a51c2b

+ Revises: bc58ec7c7d31

+ Create Date: 2021-07-23 09:20:33.885039

+ 

+ """

+ from alembic import op

+ import sqlalchemy as sa

+ from sqlalchemy.dialects import postgresql

+ 

+ # revision identifiers, used by Alembic.

+ revision = '8ec130a51c2b'

+ down_revision = 'bc58ec7c7d31'

+ 

+ 

+ def upgrade():

+     # Delete all rows in the 'update' table - some of the changes are hard to perform on existing

+     # data, and there is nothing important, everything will get synced again from Bodhi.

+     # But start with the 'update_fixes' table, because it has a foreign key to 'updates'

+     op.execute('DELETE FROM "update_fixes"')

+     # Now completely drop 'update_milestones' table, there's also a foreign key and we want to

+     # remove this table anyway.

+     op.execute('DROP TABLE "update_milestones"')

+     # There can still be a historical table 'used_updates', which was removed from code at

+     # 9d0bbd0650c, but not from the actual DB.

+     op.execute('DROP TABLE IF EXISTS "used_updates"')

+     # Now we can finally delete everything from the 'update' table

+     op.execute('DELETE FROM "update"')

+ 

+     # Alembic can't create an enum type for Postgres automatically when adding a new column

+     status_enum = postgresql.ENUM('stable', 'pending', 'testing', name='update_status_enum')

+     status_enum.create(op.get_bind())

+     request_enum = postgresql.ENUM('revoke', 'unpush', 'obsolete', 'stable', 'testing',

+                                    name='update_request_enum')

+     request_enum.create(op.get_bind())

+ 

+     # ### commands auto generated by Alembic - please adjust! ###

+     op.add_column('update', sa.Column('updateid', sa.Text(), nullable=False))

+     op.alter_column('update', 'url', existing_type=sa.TEXT(), nullable=False)

+     op.alter_column('update', 'karma', existing_type=sa.INTEGER(), nullable=False)

+     op.alter_column('update', 'date_submitted', existing_type=postgresql.TIMESTAMP(),

+                     nullable=False)

+     op.alter_column('update', 'release_id', existing_type=sa.INTEGER(), nullable=False)

+     op.create_unique_constraint(op.f('uq_update_updateid'), 'update', ['updateid'])

+     op.drop_column('update', 'pending')

+     op.drop_column('update', 'date_pushed_testing')

+     op.drop_column('update', 'date_pushed_stable')

+     op.drop_column('update', 'date_obsoleted')

+     # ### end Alembic commands ###

+ 

+     # These changes are not autodetected by Alembic

+     op.alter_column('update', 'status', existing_type=sa.VARCHAR(length=80), nullable=False,

+                     type_=status_enum, postgresql_using='status::update_status_enum')

+     op.alter_column('update', 'request', existing_type=sa.VARCHAR(length=80),

+                     type_=request_enum, postgresql_using='request::update_request_enum')

+     op.create_primary_key('pk_update_fixes', 'update_fixes', ['bug_id', 'update_id'])

+ 

+ 

+ def downgrade():

+     op.drop_constraint('pk_update_fixes', 'update_fixes', type_='primary')

+     op.alter_column('update', 'request', type_=sa.VARCHAR(length=80))

+     op.alter_column('update', 'status', type_=sa.VARCHAR(length=80), nullable=True)

+ 

+     # ### commands auto generated by Alembic - please adjust! ###

+     op.add_column('update', sa.Column('date_obsoleted', postgresql.TIMESTAMP(), autoincrement=False, nullable=True))

+     op.add_column('update', sa.Column('date_pushed_stable', postgresql.TIMESTAMP(), autoincrement=False, nullable=True))

+     op.add_column('update', sa.Column('date_pushed_testing', postgresql.TIMESTAMP(), autoincrement=False, nullable=True))

+     op.add_column('update', sa.Column('pending', sa.BOOLEAN(), autoincrement=False, nullable=True))

+     op.drop_constraint(op.f('uq_update_updateid'), 'update', type_='unique')

+     op.alter_column('update', 'release_id', existing_type=sa.INTEGER(), nullable=True)

+     op.alter_column('update', 'date_submitted', existing_type=postgresql.TIMESTAMP(), nullable=True)

+     op.alter_column('update', 'karma', existing_type=sa.INTEGER(), nullable=True)

+     op.alter_column('update', 'url', existing_type=sa.TEXT(), nullable=True)

+     op.drop_column('update', 'updateid')

+     op.create_table('update_milestones',

+                     sa.Column('milestone_id', sa.INTEGER(), autoincrement=False, nullable=True),

+                     sa.Column('update_id', sa.INTEGER(), autoincrement=False, nullable=True),

+                     sa.ForeignKeyConstraint(['milestone_id'], ['milestone.id'],

+                                             name='fk_update_milestones_milestone_id_milestone'),

+                     sa.ForeignKeyConstraint(['update_id'], ['update.id'],

+                                             name='fk_update_milestones_update_id_update')

+     )

+     # ### end Alembic commands ###

+ 

+     op.execute("DROP TYPE update_status_enum")

+     op.execute("DROP TYPE update_request_enum")

file modified
+50 -33
@@ -2,6 +2,7 @@ 

  

  import logging.handlers

  import os

+ from typing import Optional

  

  from flask import Flask, render_template

  from flask_sqlalchemy import SQLAlchemy
@@ -126,6 +127,11 @@ 

      app.wsgi_app = ProxyFix(app.wsgi_app, x_host=1)  # type: ignore[assignment]

  

  

+ # === Extra imports ===

+ # These need to be imported after all the basic setup above is done

+ import blockerbugs.models.update as model_update  # noqa: E402

+ 

+ 

  # === Flask views and stuff ===

  @app.template_filter('tagify')

  def tagify(value):
@@ -152,47 +158,58 @@ 

  

  

  @app.template_filter('updatetype')

- def updatetype(update):

+ def updatetype(update: Optional[model_update.Update]) -> str:

+     """Inspect bugs linked by this update, and return a string 'Blocker', 'FreezeException' or

+     'Prioritized' (in this priority order) if there's a at least one bug proposed as such.

+     """

+     if not update:

+         return ''

+ 

      is_fe = False

+     is_prio = False

      for bug in update.bugs:

-         if bug.proposed_blocker or bug.accepted_blocker:

-             return 'blocker'

+         if (bug.proposed_blocker or

+                 bug.accepted_blocker or

+                 bug.accepted_0day or

+                 bug.accepted_prevrel):

+             return 'Blocker'

          elif bug.proposed_fe or bug.accepted_fe:

              is_fe = True

+         elif bug.prioritized:

+             is_prio = True

      if is_fe:

-         return 'FE'

- 

- 

- @app.template_filter('updatelabel')

- def updatelabel(bug):

-     from .models.update import Update

-     label = []

-     lowest_status_update = bug.updates.filter(

-         Update.status != 'obsolete',

-         Update.status != 'deleted',

-         Update.status != 'unpushed'

-     ).first()

-     if lowest_status_update:

-         if lowest_status_update.status == 'stable':

-             if bug.status in ['MODIFIED', 'ON_QA', 'VERIFIED', 'CLOSED']:

-                 label.append('<span class="badge badge-success">')

-             else:

-                 label.append('<span class="badge badge-info">')

-             if lowest_status_update.pending:

-                 label.append('pending ')

-             label.append('stable</span>')

- 

-         elif lowest_status_update.status == 'testing':

-             label.append('<span class="badge badge-warning">')

-             if lowest_status_update.pending:

-                 label.append('pending ')

-             label.append('testing</span>')

- 

-     return ''.join(label)

+         return 'FreezeException'

+     if is_prio:

+         return 'Prioritized'

+ 

+     assert False, f"{update} doesn't seem to fix none of blocker/FE/prioritized"

+ 

+ 

+ @app.template_filter('updatestatus')

+ def updatestatus(update: Optional[model_update.Update]) -> str:

+     """Create a status description for an Update, regarding its current status and request. For

+     example 'testing' or 'testing -> stable' or 'pending -> testing'. Enclose in HTML with CSS

+     classes.

+     """

+     if not update:

+         return ''

+ 

+     text = f'<b>{update.status}</b>'

+     if update.request:

+         text += f' <span class="fas fa-arrow-right"></span> {update.request}'

+ 

+     if update.status == 'stable':

+         css_class = 'badge badge-success'

+     elif update.status == 'testing':

+         css_class = 'badge badge-warning'

+     else:

+         css_class = 'badge badge-info'

+ 

+     return f'<span class="{css_class}">{text}</span>'

  

  

  @app.template_filter('datetime')

- def datetime_format(value, format='%Y-%m-%d %H:%M:%S UTC'):

+ def datetime_format(value, format='%Y-%m-%d %H:%M UTC'):

      if value is not None:

          return value.strftime(format)

      return ''

file modified
+21
@@ -14,6 +14,7 @@ 

  import blockerbugs.util.discussion_sync as discussion_sync

  import blockerbugs.util.pagure_bot as pagure_bot

  from blockerbugs.models.bug import Bug

+ from blockerbugs.util import testdata

  from alembic.config import Config as al_Config

  from alembic import command as al_command

  from alembic import script
@@ -238,6 +239,16 @@ 

      discussion_sync.close_discussions_inactive_releases(args.dryrun)

  

  

+ def create_test_data(args):

+     """Create fake test data in the database"""

+     testdata.create_test_data()

+ 

+ 

+ def remove_test_data(args):

+     """Remove fake test data in the database"""

+     testdata.remove_test_data()

+ 

+ 

  def main():

      parser = ArgumentParser()

  
@@ -331,6 +342,16 @@ 

                                                     help="Don't make any actual changes")

      close_inactive_discussions_parser.set_defaults(func=close_inactive_discussions)

  

+     create_test_data_parser = subparsers.add_parser(

+         'create-test-data', help='Create fake test data under release 101. Can be called '

+         'repeatedly, always deletes everything under that release and creates test data anew. '

+         'WARNING: Never run this if you have actual real data under release 101!')

+     create_test_data_parser.set_defaults(func=create_test_data)

+ 

+     remove_test_data_parser = subparsers.add_parser(

+         'remove-test-data', help='Remove fake test data. Removes release 101 and everything under '

+         'it. WARNING: Never run this if you have actual real data under release 101!')

+     remove_test_data_parser.set_defaults(func=remove_test_data)

  

      args = parser.parse_args()

  

@@ -20,9 +20,12 @@ 

  

  """RESTful API handlers"""

  

+ from typing import Any

+ 

  from flask import Blueprint, request

  

- from blockerbugs import app, db

+ from blockerbugs import app

+ from blockerbugs.controllers.main import update_to_milestones

  from blockerbugs.models.milestone import Milestone

  from blockerbugs.models.update import Update

  from blockerbugs.models.release import Release
@@ -47,15 +50,17 @@ 

      return JsonResponse(e.to_dict(), e.http_status_code)

  

  

- def get_update_info(update):

-     update_simple_fields = ['title', 'url', 'karma', 'stable_karma', 'status',

-                             'pending']

+ def get_update_info(update: Update) -> dict[str, Any]:

+     """Create a per-update response dictionary to be used in ``list_updates()``.

+     """

+     update_simple_fields = ['updateid', 'title', 'url', 'karma', 'stable_karma', 'status',

+                             'request']

      update_data = dict(

          (attr, getattr(update, attr)) for attr in update_simple_fields)

      update_data['release'] = update.release.number

      update_data['milestones'] = [{'version': m.version,

-                                   'release': m.release.number, }

-                                  for m in update.milestones]

+                                   'release': m.release.number if m.release else -1, }

+                                  for m in update_to_milestones(update)]

      update_data['bugs'] = [

          {'bugid': bug.bugid,

           'type': [tp for tp in ACCEPTED_BUGTYPES if getattr(bug, tp)]}
@@ -70,22 +75,31 @@ 

      bug_info['type'] = [tp for tp in ACCEPTED_BUGTYPES if getattr(bug, tp)]

      return bug_info

  

+ 

  @api_v0.route('/milestones/<int:rel_num>/<milestone_version>/updates')

- def list_updates(rel_num, milestone_version):

+ def list_updates(rel_num: int, milestone_version: str) -> JsonResponse:

+     """List all Updates which claim to fix a tracked bug created under the specified ``milestone``.

+     """

      release = get_or_404(Release, number=rel_num)

      milestone = get_or_404(Milestone, release=release,

                             version=milestone_version)

-     m_alias = db.aliased(Milestone)

-     updates = Update.query.join(m_alias, Update.milestones).filter(

-         m_alias.id == milestone.id)

+ 

+     updates = Update.query.filter_by(

+             release=milestone.release,

+         ).join(Update.bugs).filter(

+             Bug.milestone == milestone,

+             Bug.active == True,  # noqa: E712

+         )

+ 

      if 'bugtype' in request.args:

          bugtype = request.args['bugtype']

          if bugtype in ACCEPTED_BUGTYPES:

              bug_attr = getattr(Bug, bugtype)

-             updates = updates.filter(Update.bugs.any(bug_attr == True))

+             updates = updates.filter(bug_attr == True)  # noqa: E712

          else:

              raise errors.InvalidArgumentError(arg_name='bugtype')

-     updates = updates.order_by(Update.date_submitted.desc()).all()

+ 

+     updates = updates.order_by(Update.date_submitted.desc()).all()  # type: ignore[attr-defined]

      updates_info = [get_update_info(up) for up in updates]

      return JsonResponse(updates_info)

  

file modified
+68 -92
@@ -21,7 +21,7 @@ 

  

  from flask import Blueprint, render_template, redirect, url_for, abort, g, flash, make_response, request

  import datetime

- from sqlalchemy import func, desc, or_, and_

+ from sqlalchemy import func, desc, or_

  import bugzilla

  from flask_fas_openid import fas_login_required

  import json
@@ -88,76 +88,67 @@ 

      return bugz

  

  

- def get_milestone_pending_stable_updates(milestone):

+ def get_milestone_updates(milestone: Milestone) -> list[Update]:

+     """Get all Updates which claim to fix some blocker/FE/prioritized bug which is proposed or

+     accepted against the specified ``milestone``.

      """

-     return list of updates associated with milestone,

-     which are 'pending stable' in bodhi

-     and are marked as fixing any non-rejected blocker/fe bug

-     """

-     m_alias = db.aliased(Milestone)

-     updates = Update.query.join(m_alias, Update.milestones).filter(

-         m_alias.id == milestone.id,

-         Update.status == u'stable', Update.pending == True,

-         Update.bugs.any(

-             or_(Bug.accepted_fe == True, Bug.accepted_blocker == True,

-                 Bug.accepted_0day == True, Bug.accepted_prevrel == True,

-                 Bug.proposed_fe == True, Bug.proposed_blocker == True))).all()

+     updates = Update.query.filter_by(

+             release=milestone.release,

+         ).join(Update.bugs).filter(

+             Bug.milestone == milestone,

+             Bug.active == True,  # noqa: E712

+             Bug.is_proposed_accepted == True,

+         ).all()

+ 

      return updates

  

  

- def get_milestone_updates_testing(milestone):

+ def update_to_milestones(update: Update) -> list[Milestone]:

+     """Determine which milestones this update is relevant to. That is computed from update's

+     release, and milestones of bugs which this update fixes.

      """

-     return list of updates associated with milestone,

-     which are 'updates testing' in bodhi (not updates testing pending)

-     and are marked as fixing any non-rejected blocker/fe bug

-     """

-     m_alias = db.aliased(Milestone)

-     updates = Update.query.join(m_alias, Update.milestones).filter(

-         m_alias.id == milestone.id, Update.status == 'testing',

-         Update.karma < Update.stable_karma,

-         or_(Update.pending != True, Update.pending == None),

-         Update.bugs.any(

-             or_(Bug.accepted_fe == True, Bug.accepted_blocker == True,

-                 Bug.accepted_0day == True, Bug.accepted_prevrel == True,

-                 Bug.proposed_fe == True, Bug.proposed_blocker == True))).all()

-     return updates

+     milestones = Milestone.query.filter_by(

+             release=update.release,

+         ).join(Milestone.bugs).join(Bug.updates).filter(

+             Update.id == update.id,

+         ).all()

  

- def get_milestone_all_nonstable_blocker_fixes(milestone):

-     """

-     return list of all non-stable, non-obsolete updates which are

-     marked as fixing any accepted blocker bug for milestone

+     return milestones

+ 

+ 

+ def get_updates_nonstable_blockers(milestone: Milestone) -> list[Update]:

+     """Get a list of Updates which are not yet 'stable' in Bodhi, and they claim to fix some blocker

+     bug which is accepted as an 'AcceptedBlocker' or 'Accepted0Day' (but not

+     'AcceptedPreviousRelease') against the specified ``milestone``.

+ 

+     This is useful when creating requests for freeze pushes or new candidate composes.

      """

-     m_alias = db.aliased(Milestone)

-     updates = Update.query.join(m_alias, Update.milestones).filter(

-         m_alias.id == milestone.id, ~Update.status.in_(['obsolete', 'deleted', 'unpushed']),

-         or_(Update.status != 'stable', Update.pending == True),

-         Update.bugs.any(

-             and_(

-                 Bug.milestone_id == milestone.id,

-                 or_(

-                     Bug.accepted_blocker == True, Bug.accepted_0day == True

-                 )

-             )

-         )

-     ).all()

+     updates = Update.query.filter(

+             Update.release == milestone.release,

+             Update.status != 'stable',

+         ).join(Update.bugs).filter(

+             Bug.milestone == milestone,

+             or_(Bug.accepted_blocker == True,  # noqa: E712

+                 Bug.accepted_0day == True),

+         ).all()

+ 

      return updates

  

- def get_milestone_all_nonstable_fe_fixes(milestone):

-     """

-     return list of all non-stable, non-obsolete updates which are

-     marked as fixing any accepted FE bug for milestone

+ 

+ def get_updates_nonstable_FEs(milestone: Milestone) -> list[Update]:

+     """Get a list of Updates which are not yet 'stable' in Bodhi, and they claim to fix some FE bug

+     which is accepted against the specified ``milestone``.

+ 

+     This is useful when creating requests for freeze pushes or new candidate composes.

      """

-     m_alias = db.aliased(Milestone)

-     updates = Update.query.join(m_alias, Update.milestones).filter(

-         m_alias.id == milestone.id,  ~Update.status.in_(['obsolete', 'deleted', 'unpushed']),

-         or_(Update.status != 'stable', Update.pending == True),

-         Update.bugs.any(

-             and_(

-                 Bug.milestone_id == milestone.id,

-                 Bug.accepted_fe == True

-             )

-         )

-     ).all()

+     updates = Update.query.filter(

+             Update.release == milestone.release,

+             Update.status != 'stable',

+         ).join(Update.bugs).filter(

+             Bug.milestone == milestone,

+             Bug.accepted_fe == True,  # noqa: E712

+         ).all()

+ 

      return updates

  

  
@@ -312,16 +303,14 @@ 

  

  

  @main.route('/bug/<int:bugid>/updates')

- def display_bug_updates(bugid):

+ def display_bug_updates(bugid: int) -> str:

+     """Return HTML with all Bodhi updates related to a certain Bugzilla ticket.

+     """

      bug = Bug.query.filter_by(bugid=bugid).first()

      if not bug:

          abort(404)

      packagename = bug.component

-     updates = bug.updates.filter(

-         Update.status != 'obsolete',

-         Update.status != 'deleted',

-         Update.status != 'unpushed'

-     ).all()

+     updates = bug.updates.all()

      return render_template('bug_tooltip.html', packagename=packagename, updates=updates,

                             bz_url=app.config['BUGZILLA_URL'])

  
@@ -349,21 +338,21 @@ 

      return response

  

  

- @main.route('/milestone/<int:num>/<release_name>/updates')

- def display_release_updates(num, release_name):

-     release = Release.query.filter_by(number=num).first()

-     milestone = Milestone.query.filter_by(release=release, version=release_name).first()

+ @main.route('/milestone/<int:release_num>/<milestone_version>/updates')

+ def display_release_updates(release_num: int, milestone_version: str) -> str:

+     """Render a template showing important updates for the selected milestone.

+     """

+     release = Release.query.filter_by(number=release_num).first()

+     milestone = Milestone.query.filter_by(release=release, version=milestone_version).first()

      if not milestone:

          abort(404)

      milestone_info = get_milestone_info(milestone)

-     updates = {

-         'Non Stable Updates': get_milestone_pending_stable_updates(milestone),

-         'Updates Needing Testing': get_milestone_updates_testing(milestone),

-     }

+     updates = get_milestone_updates(milestone)

      return render_template('update_list.html',

-                            info=milestone_info,

                             updates=updates,

-                            title="Fedora %s %s Blocker Bug Updates" % (milestone_info['number'], milestone_info['phase']))

+                            milestone=milestone,

+                            title="Fedora %s %s Blocker Bug Updates" % (milestone_info['number'],

+                                                                        milestone_info['phase']))

  

  

  @main.route('/milestone/<int:num>/<release_name>/requests')
@@ -372,8 +361,8 @@ 

      milestone = Milestone.query.filter_by(release=release, version=release_name).first()

      if not milestone:

          abort(404)

-     blocker_updates = get_milestone_all_nonstable_blocker_fixes(milestone)

-     fe_updates = get_milestone_all_nonstable_fe_fixes(milestone)

+     blocker_updates = get_updates_nonstable_blockers(milestone)

+     fe_updates = get_updates_nonstable_FEs(milestone)

      # if an update fixes both blockers and FEs, drop it from FE list

      fe_updates = [fe for fe in fe_updates if fe not in blocker_updates]

      # highlight accepted bugs which have some dependencies
@@ -388,19 +377,6 @@ 

      return response

  

  

- @main.route('/milestone/<int:num>/<milestone_name>/need_testing')

- def display_updates_need_testing(num, milestone_name):

-     release = Release.query.filter_by(number=num).first()

-     milestone = Milestone.query.filter_by(release=release, version=milestone_name).first()

-     if not milestone:

-         abort(404)

-     milestone_info = get_milestone_info(milestone)

-     updates = get_milestone_updates_testing(milestone)

-     return render_template('update_need_testing.html', updates=updates,

-                            info=milestone_info,

-                            title="Fedora %s %s Blocker Bug Updates" % (milestone_info['number'], milestone_info['phase']))

- 

- 

  @main.route('/milestone/<int:num>/<milestone_name>/info')

  def display_milestone_info(num, milestone_name):

      release = Release.query.filter_by(number=num).first()

@@ -27,14 +27,8 @@ 

  

  update_fixes: Any = db.Table(

      'update_fixes',

-     db.Column('bug_id', db.Integer, db.ForeignKey('bug.id')),

-     db.Column('update_id', db.Integer, db.ForeignKey('update.id'))

- )

- 

- update_milestones: Any = db.Table(

-     'update_milestones',

-     db.Column('milestone_id', db.Integer, db.ForeignKey('milestone.id')),

-     db.Column('update_id', db.Integer, db.ForeignKey('update.id'))

+     db.Column('update_id', db.Integer, db.ForeignKey('update.id'), primary_key=True),

+     db.Column('bug_id', db.Integer, db.ForeignKey('bug.id'), primary_key=True),

  )

  

  

file modified
+32 -1
@@ -23,6 +23,9 @@ 

  import json

  from typing import Any, Optional

  

+ from sqlalchemy import or_

+ from sqlalchemy.ext.hybrid import hybrid_property

+ 

  from blockerbugs import db, BaseModel, models

  import blockerbugs.models.milestone as model_milestone

  import blockerbugs.models.update as model_update
@@ -79,7 +82,8 @@ 

      """A JSON list of bug numbers which this bug depends on"""

      updates: list['model_update.Update'] = db.relationship(

          'Update', secondary=models.update_fixes, back_populates='bugs', lazy='dynamic',

-         order_by=(model_update.Update.status.desc(), model_update.Update.pending.desc()))

+         order_by=('[Update.status.desc(), Update.request.desc()]')

+     )

  

      def __init__(self,

                   bugid: Optional[int],
@@ -126,6 +130,33 @@ 

      def depends_on(self, value: Optional[list[int]]) -> None:

          self._depends_on = json.dumps(value or [])

  

+     @hybrid_property

+     def is_proposed_accepted(self) -> bool:

+         """Return ``True`` if this bug is either proposed or accepted as a Blocker, FreezeException

+         or Prioritized.

+         """

+         return (self.proposed_blocker or

+                 self.proposed_fe or

+                 self.accepted_blocker or

+                 self.accepted_0day or

+                 self.accepted_prevrel or

+                 self.accepted_fe or

+                 self.prioritized)

+ 

+     @is_proposed_accepted.expression  # type: ignore[no-redef]

+     def is_proposed_accepted(cls) -> bool:

+         """Return ``True`` if this bug is either proposed or accepted as a Blocker, FreezeException

+         or Prioritized.

+         """

+         # The SQLAlchemy expression when using ``is_proposed_accepted`` in a query.

+         return or_(cls.proposed_blocker,

+                    cls.proposed_fe,

+                    cls.accepted_blocker,

+                    cls.accepted_0day,

+                    cls.accepted_prevrel,

+                    cls.accepted_fe,

+                    cls.prioritized)

+ 

      def __repr__(self):

          return '<bug %d: %s>' % (self.bugid, self.summary)

  

@@ -21,8 +21,8 @@ 

  

  from typing import Any, Optional

  

- from blockerbugs import db, BaseModel, models

- from blockerbugs.models import bug, criterion, update

+ from blockerbugs import db, BaseModel

+ from blockerbugs.models import bug, criterion

  from blockerbugs.models import release as model_release

  

  
@@ -51,8 +51,6 @@ 

      """Current milestone is the most relevant one currently. Usually it is the nearest milestone

      in the future. There should be at most one milestone marked as current."""

      bugs: list['bug.Bug'] = db.relationship('Bug', back_populates='milestone', lazy='dynamic')

-     updates: list['update.Update'] = db.relationship('Update', secondary=models.update_milestones,

-                                                      back_populates='milestones')

      criteria: list['criterion.Criterion'] = db.relationship('Criterion', back_populates='milestone',

                                                              lazy='dynamic')

      succeeds_id = db.Column(db.Integer, db.ForeignKey('milestone.id'), nullable=True)

file modified
+93 -54
@@ -19,75 +19,114 @@ 

  

  """Database model for Bodhi updates"""

  

+ from typing import Optional, Any

+ from datetime import datetime

+ 

  from blockerbugs import db, BaseModel, models

- from blockerbugs.models import bug, milestone

+ from blockerbugs.models import bug

  from blockerbugs.models import release as model_release

  

  

  class Update(BaseModel):

-     id = db.Column(db.Integer, primary_key=True)

-     title = db.Column(db.Text, unique=False)

-     url = db.Column(db.Text, unique=False)

-     karma = db.Column(db.Integer, unique=False)

-     stable_karma = db.Column(db.Integer, unique=False)

-     status = db.Column(db.String(80), unique=False)

-     request = db.Column(db.String(80), unique=False, nullable=True)

-     pending = db.Column(db.Boolean(create_constraint=True, name='pending_bool'), unique=False)

-     date_submitted = db.Column(db.DateTime)

-     date_pushed_testing = db.Column(db.DateTime, nullable=True)

-     date_pushed_stable = db.Column(db.DateTime, nullable=True)

-     date_obsoleted = db.Column(db.DateTime, nullable=True)

+     """A representation of a Bodhi update.

+ 

+     Bodhi update states are documented here:

+     https://bodhi.fedoraproject.org/docs/user/update_states.html

+     And here's a JSON schema for the different fields:

+     https://bodhi.fedoraproject.org/docs/server_api/messages/update.html#json-schemas

+     """

+     id: int = db.Column(db.Integer, primary_key=True)

+     updateid: str = db.Column(db.Text, nullable=False, unique=True)

+     """E.g. FEDORA-2021-5282b5cafd"""

+     release_id: int = db.Column(db.Integer, db.ForeignKey('release.id'), nullable=False)

+     release: 'model_release.Release' = db.relationship('Release', back_populates='updates')

+     """The release this update was created for in Bodhi"""

+     status: str = db.Column(db.Enum('stable', 'pending', 'testing', create_constraint=True,

+                                     name='update_status_enum'),

+                             nullable=False)

+     """One of: 'stable', 'pending', 'testing'. A Bodhi update can have additional values, but we

+     only allow these three here. Because this is an enum, Updates can be sorted by this in the

+     specified order (updates in a need of testing have higher value)."""

+     karma: int = db.Column(db.Integer, nullable=False)

+     """Current karma value"""

+     url: str = db.Column(db.Text, nullable=False)

+     """E.g. https://bodhi.fedoraproject.org/updates/FEDORA-2021-5282b5cafd"""

+     date_submitted: datetime = db.Column(db.DateTime, nullable=False)

+     """When the update was created"""

+     request: Optional[str] = db.Column(db.Enum('revoke', 'unpush', 'obsolete', 'stable', 'testing',

+                                                create_constraint=True, name='update_request_enum'))

+     """One of: 'revoke', 'unpush', 'obsolete', 'stable', 'testing', None. Because this is an enum,

+     Updates can be sorted by this in the specified order (updates in a need of testing have higher

+     value)."""

+     title: Optional[str] = db.Column(db.Text)

+     """E.g. firewalld-0.9.4-1.fc34"""

+     stable_karma: Optional[int] = db.Column(db.Integer)

+     """Target karma value for allowing the update to go stable (via auto or manual push)."""

      bugs: list['bug.Bug'] = db.relationship(

          'Bug', secondary=models.update_fixes, back_populates='updates')

-     release_id = db.Column(db.Integer, db.ForeignKey('release.id'))

-     release: 'model_release.Release' = db.relationship('Release', back_populates='updates')

-     milestones: list['milestone.Milestone'] = db.relationship(

-         'Milestone', secondary=models.update_milestones, back_populates='updates')

+     """A list of bugs this update claims to fix *and* we track them"""

  

-     def __init__(self, title, url, karma, status, bugs, release,

-                  milestones, stable_karma=3, date_submitted=None):

-         self.title = title

-         self.url = url

-         self.karma = karma

+     _tmpstr = 'A placeholder value when creating incomplete Update objects'

+ 

+     def __init__(self,

+                  updateid: str,

+                  release: 'model_release.Release',

+                  status: str,

+                  karma: int,

+                  url: str,

+                  date_submitted: datetime,

+                  request: Optional[str] = None,

+                  title: Optional[str] = None,

+                  stable_karma: Optional[int] = None,

+                  bugs: list['bug.Bug'] = []) -> None:

+         self.updateid = updateid

+         self.release = release

          self.status = status

-         self.request = None

-         self.bugs = bugs

+         self.karma = karma

+         self.url = url

          self.date_submitted = date_submitted

-         self.release = release

-         if milestones:

-             self.milestones = milestones

-         self.date_pushed_testing = None

-         self.date_pushed_stable = None

-         self.date_obsoleted = None

+         self.request = request

+         self.title = title

+         self.stable_karma = stable_karma

+         self.bugs = bugs

  

-     def __str__(self):

-         return 'update: %s' % (self.title)

+     def __repr__(self) -> str:

+         return f'<Update(id={self.id},updateid={self.updateid})>'

  

-     def sync(self, updateinfo):

-         self.title = updateinfo['title']

-         self.url = updateinfo['url']

-         self.karma = updateinfo['karma']

-         self.stable_karma = updateinfo['stable_karma']

+     def sync(self, updateinfo: dict[str, Any]) -> None:

+         self.updateid = updateinfo['updateid']

          self.status = updateinfo['status']

-         self.request = updateinfo['request']

+         self.karma = updateinfo['karma']

+         self.url = updateinfo['url']

          self.date_submitted = updateinfo['date_submitted']

-         if updateinfo['date_pushed_testing']:

-             self.date_pushed_testing = updateinfo['date_pushed_testing']

-         if updateinfo['date_pushed_stable']:

-             self.date_pushed_stable = updateinfo['date_pushed_stable']

-         self.pending = updateinfo['pending']

-         current_bugkeys = [(currentbug.bugid, currentbug.milestone) for currentbug in self.bugs]

-         for bugid in updateinfo['bugs']:

-             newbugs = bug.Bug.query.filter_by(bugid=bugid).all()

-             for newbug in newbugs:

-                 if not (newbug.bugid, newbug.milestone) in current_bugkeys:

-                     self.bugs.append(newbug)

-                     current_bugkeys.append((newbug.bugid, newbug.milestone))

-                 if newbug.milestone and newbug.milestone not in self.milestones:

-                     self.milestones.append(newbug.milestone)

+         self.request = updateinfo['request']

+         self.title = updateinfo['title']

+         self.stable_karma = updateinfo['stable_karma']

+ 

+         self.bugs.clear()

+         for bugid in set(updateinfo['bugs']):  # deduplicate just to be sure

+             assert isinstance(bugid, int)

+             tracked_bugs = bug.Bug.query.filter_by(bugid=bugid).all()

+             for tracked_bug in tracked_bugs:

+                 self.bugs.append(tracked_bug)

+ 

+         # a quick check that mandatory values seem initialized

+         checkfields = [self.updateid, self.release, self.status, self.karma, self.url,

+                        self.date_submitted, self.bugs]

+         assert None not in checkfields

+         assert self._tmpstr not in checkfields

  

      @classmethod

-     def from_data(cls, updateinfo, release):

-         newupdate = Update(updateinfo['title'], '', 0, '', [], release, None)

+     def from_data(cls, updateinfo: dict[str, Any], release: 'model_release.Release') -> 'Update':

+         newupdate = Update(updateid=updateinfo['updateid'],

+                            release=release,

+                            status=cls._tmpstr,

+                            karma=-99,

+                            url=cls._tmpstr,

+                            date_submitted=datetime.utcfromtimestamp(0),

+                            request=None,

+                            title=cls._tmpstr,

+                            stable_karma=None,

+                            bugs=[])

          newupdate.sync(updateinfo)

          return newupdate

@@ -188,4 +188,9 @@ 

    width: 60% !important;

  }

  

+ .badge {

+   font-size: 0.9em;

+   font-weight: inherit;

+ }

+ 

  /*# sourceMappingURL=app-bootstrap.css.map */

@@ -66,8 +66,8 @@ 

                      <th scope="col" class="normal">Component</th>

                      <th scope="col" class="narrow">Status</th>

                      <th scope="col" class="sorter-false filter-false wide">Title</th>

+                     <th scope="col" class="normal">Updates</th>

                      <th scope="col" class="sorter-false filter-false narrow">Review</th>

-                     <th scope="col" class="narrow">Updates</th>

                  </tr>

              </thead>

              <tbody>
@@ -87,6 +87,18 @@ 

                          <td>{{ bug.component }}</td>

                          <td>{{ bug.status }}</td>

                          <td>{{ bug.summary }}</td>

+                         {% set update_html = bug.updates.first() | updatestatus | safe %}

+                         {% if update_html %}

+                             {% set num_updates = bug.updates | list | length %}

+                             <td class="popupification text-nowrap">

+                                 <a href="{{ url_for('main.display_bug_updates', bugid=bug.bugid) }}">{{ update_html }}</a>

+                                 {% if num_updates > 1 %}

+                                     ({{ num_updates }})

+                                 {% endif %}

+                             </td>

+                         {% else %}

+                             <td></td>

+                         {% endif %}

                          <td class="text-center">

                              {% if buglist.startswith('Proposed') and vote_info[bug.bugid][buglist] %}

                                  <span class="text-nowrap">
@@ -112,14 +124,6 @@ 

                                  TBD

                              {% endif %}

                          </td>

-                         {% set update_html = bug | updatelabel | safe %}

-                         {% if update_html %}

-                             <td class="popupification">

-                                 <a href="{{ url_for('main.display_bug_updates', bugid=bug.bugid) }}">{{ update_html }}</a>

-                             </td>

-                         {% else %}

-                             <td></td>

-                         {% endif %}

                      </tr>

                  {% else %}

                      <tr>

@@ -1,27 +1,27 @@ 

  <div>

      <div class="row">

          <div class="col-md-12" style="padding-bottom: 1.5em;">

-                 <h4>Potential fixes</h4>

-                 <table class='tiptable'>

-                     <thead class="thead-light">

-                         <tr>

-                             <th scope="col">Update</th>

-                             <th scope="col">Karma</th>

-                             <th scope="col">Status</th>

-                             <th scope="col">Time</th>

-                         </tr>

-                     </thead>

-                     <tbody>

-                         {% for update in updates %}

-                         <tr>

-                             <td><a href="{{ update.url }}" target="_blank" rel="noopener">{{ update.title }}</a></td>

-                             <td>{{ update.karma }}</td>

-                             <td>{{ update.status }}</td>

-                             <td>{{ update.date_submitted }}</td>

-                         </tr>

-                         {% endfor %}

-                     </tbody>

-                 </table>

+               <h4>Potential fixes</h4>

+               <table class='tiptable'>

+                   <thead class="thead-light">

+                       <tr>

+                           <th scope="col">Update</th>

+                           <th scope="col">Karma</th>

+                           <th scope="col">Status</th>

+                           <th scope="col">Created</th>

+                       </tr>

+                   </thead>

+                   <tbody>

+                       {% for update in updates %}

+                           <tr class="update">

+                               <td><a href="{{ update.url }}" target="_blank" rel="noopener">{{ update.title or update.updateid }}</a></td>

+                               <td>{{ update.karma }}{% if update.stable_karma %}/{{ update.stable_karma }}{% endif %}</td>

+                               <td>{{ update | updatestatus | safe }}</td>

+                               <td>{{ update.date_submitted | datetime }}</td>

+                           </tr>

+                       {% endfor %}

+                   </tbody>

+               </table>

          </div>

      </div>

      <div class="row">

@@ -3,12 +3,12 @@ 

  

  == Blockers ==

  {% for update in blocker_updates %}

- * [{{ update.title }}]({{ update.url }}) for {%- for bug in update.bugs if bug.milestone_id == milestone and (bug.accepted_blocker or bug.accepted_0day or bug.accepted_fe) %} [#{{ bug.bugid }}]({{ bug.url }}){% if bug.accepted_fe and not bug.accepted_blocker and not bug.accepted_prevrel and not bug.accepted_0day %} (FE){% endif %}{% endfor %}

+ * [{{ update.title or update.updateid }}]({{ update.url }}) for {%- for bug in update.bugs if bug.milestone_id == milestone and (bug.accepted_blocker or bug.accepted_0day or bug.accepted_fe) %} [#{{ bug.bugid }}]({{ bug.url }}){% if bug.accepted_fe and not bug.accepted_blocker and not bug.accepted_prevrel and not bug.accepted_0day %} (FE){% endif %}{% endfor %}

  {%- endfor %}

  

  == Freeze exceptions ==

  {% for update in fe_updates %}

- * [{{ update.title }}]({{ update.url }}) for {%- for bug in update.bugs if bug.milestone_id == milestone and (bug.accepted_blocker or bug.accepted_0day or bug.accepted_fe) %} [#{{ bug.bugid }}]({{ bug.url }}){% endfor %}

+ * [{{ update.title or update.updateid }}]({{ update.url }}) for {%- for bug in update.bugs if bug.milestone_id == milestone and (bug.accepted_blocker or bug.accepted_0day or bug.accepted_fe) %} [#{{ bug.bugid }}]({{ bug.url }}){% endfor %}

  {%- endfor %}

  

  
@@ -16,12 +16,12 @@ 

  

  == Blockers ==

  {% for update in blocker_updates if update.request == 'stable' %}

- * [{{ update.title }}]({{ update.url }}) for {%- for bug in update.bugs if bug.milestone_id == milestone and (bug.accepted_blocker or bug.accepted_0day or bug.accepted_fe) %} [#{{ bug.bugid }}]({{ bug.url }}){% if bug.accepted_fe and not bug.accepted_blocker and not bug.accepted_prevrel and not bug.accepted_0day %} (FE){% endif %}{% endfor %}

+ * [{{ update.title or update.updateid }}]({{ update.url }}) for {%- for bug in update.bugs if bug.milestone_id == milestone and (bug.accepted_blocker or bug.accepted_0day or bug.accepted_fe) %} [#{{ bug.bugid }}]({{ bug.url }}){% if bug.accepted_fe and not bug.accepted_blocker and not bug.accepted_prevrel and not bug.accepted_0day %} (FE){% endif %}{% endfor %}

  {%- endfor %}

  

  == Freeze exceptions ==

  {% for update in fe_updates if update.request == 'stable' %}

- * [{{ update.title }}]({{ update.url }}) for {%- for bug in update.bugs if bug.milestone_id == milestone and (bug.accepted_blocker or bug.accepted_0day or bug.accepted_fe) %} [#{{ bug.bugid }}]({{ bug.url }}){% endfor %}

+ * [{{ update.title or update.updateid }}]({{ update.url }}) for {%- for bug in update.bugs if bug.milestone_id == milestone and (bug.accepted_blocker or bug.accepted_0day or bug.accepted_fe) %} [#{{ bug.bugid }}]({{ bug.url }}){% endfor %}

  {%- endfor %}

  {% endautoescape %}

  

@@ -2,34 +2,20 @@ 

  

  {% block jsheader %}

      <script type="text/javascript">

-         $(document).ready(function()

-                 {

-                 {% for update_list in ['Non Stable Updates', 'Updates Needing Testing'] %}

-                 $("#{{ update_list|tagify }}").tablesorter({theme: "bootstrap", sortList: [[2,0],[1,0]]});

-                 {% endfor %}

+         $(document).ready(function() {

+             $("#tracked-updates").tablesorter({theme: "bootstrap", sortList: [[0,0],[3,1]]});

          });

      </script>

  

-     {% endblock %}

+ {% endblock %}

  

- {# this should be imported from somewhere else, not pasted #}

- {% macro statustext(update) %}

- {% if update %}

- {% if update.pending %}

- pending

- {% endif %}

- {{ update.status }}

- {% endif %}

- {% endmacro %}

  

  {% block body %}

  

- 

  <div class="row">

      <div class="col-md-12" id="updatetables">

-         {% for update_list in ['Non Stable Updates', 'Updates Needing Testing'] %}

-         <h2>{{ update_list }}</h2>

-         <table id="{{ update_list | tagify }}" cellspacing="1" class="table">

+         <h2>Updates fixing tracked bugs</h2>

+         <table id="tracked-updates" cellspacing="1" class="table">

              <thead class="thead-light">

                  <tr>

                      <th scope="col">Type</th>
@@ -40,19 +26,21 @@ 

                  </tr>

              </thead>

              <tbody>

-                 {% for update in updates[update_list] %}

+                 {% for update in updates %}

+                     {% set relevant_bugs = update.bugs | selectattr('active') |

+                        selectattr('milestone', '==', milestone) | selectattr('is_proposed_accepted') |

+                        list %}

                      <tr>

                          <td>{{ update | updatetype }}</td>

-                         <td>{{ update.bugs[0].component }}</td>

-                         <td><a href='{{ update.url }}'>'{{ update.title }}'</a></td>

-                         <td>{{ statustext(update) }}</td>

+                         <td>{{ relevant_bugs | map(attribute='component') | sort | join(', ') }}</td>

+                         <td><a href='{{ update.url }}'>{{ update.title or update.updateid }}</a></td>

+                         <td>{{ update | updatestatus | safe }}</td>

                          <td>

-                             {% for bug in update.bugs %}

-                                 {%  if (bug.proposed_blocker or bug.proposed_fe or bug.accepted_blocker or bug.accepted_fe or bug.accepted_0day or bug.accepted_prevrel) %}

-                                     <a href={{ bug.url }} title={{ bug.summary }}>

-                                         {{ bug.bugid }}

-                                     </a>

-                                 {% endif %}

+                             {% for bug in relevant_bugs %}

+                                 <a href="{{ bug.url }}" title="{{ bug.summary }}">

+                                     {{- bug.bugid -}}

+                                 </a>

+                                 {%- if not loop.last %}, {% endif %}

                              {% endfor %}

                          </td>

                      </tr>
@@ -67,7 +55,6 @@ 

                  {% endfor %}

              </tbody>

          </table>

-     {% endfor %}

      </div> <!-- end of 12 columns -->

  </div> <!-- end of row -->

  

@@ -0,0 +1,248 @@ 

+ """Manipulate test data in the DB in order to develop/experiment with the BlockerBugs app"""

+ 

+ import datetime

+ 

+ from blockerbugs import db

+ from blockerbugs.models.update import Update

+ from blockerbugs.models.bug import Bug

+ from blockerbugs.models.release import Release

+ from blockerbugs.models.milestone import Milestone

+ 

+ zen = str.splitlines('''\

+ Beautiful is better than ugly.

+ Explicit is better than implicit.

+ Simple is better than complex.

+ Complex is better than complicated.

+ Flat is better than nested.

+ Sparse is better than dense.

+ Readability counts.

+ Special cases aren't special enough to break the rules.

+ Although practicality beats purity.

+ Errors should never pass silently.

+ Unless explicitly silenced.

+ In the face of ambiguity, refuse the temptation to guess.

+ There should be one-- and preferably only one --obvious way to do it.

+ Although that way may not be obvious at first unless you're Dutch.

+ Now is better than never.

+ Although never is often better than *right* now.

+ If the implementation is hard to explain, it's a bad idea.

+ If the implementation is easy to explain, it may be a good idea.

+ Namespaces are one honking great idea -- let's do more of those!''')

+ virtues = [

+     'perfection',

+     'generosity',

+     'proper conduct',

+     'renunciation',

+     'wisdom',

+     'energy',

+     'patience',

+     'honesty',

+     'determination',

+     'goodwill',

+     'equanimity',

+     'non-attachment',

+     'benevolence',

+     'understanding',

+     'compassion',

+     'empathetic joy',

+     'heedfulness',

+     'mindfulness',

+     'clear comprehension',

+     'discrimination',

+     'trust',

+     'confidence',

+     'self-respect',

+     'decorum',

+     'giving',

+     'non-violence',

+ ]

+ 

+ zen_counter = 0

+ '''The next zen line to use'''

+ virtues_counter = 0

+ '''The next virtues line to use'''

+ 

+ current_date = datetime.datetime.utcnow()

+ month_old_date = current_date - datetime.timedelta(days=30)

+ 

+ 

+ def get_zen():

+     """Be told a next Zen wisdom. You can use this for bug titles, but you can also use it to

+     improve your life.

+     """

+     global zen_counter

+     try:

+         wisdom = zen[zen_counter]

+     except IndexError:

+         wisdom = f'Zen quotes are a finite resource, use them well (advice #{zen_counter})'

+     zen_counter += 1

+     return wisdom

+ 

+ 

+ def get_virtue():

+     """Be told a next virtue. You can use it for bug components, but also for self-reflection.

+     """

+     global virtues_counter

+     try:

+         virtue = virtues[virtues_counter]

+     except IndexError:

+         virtue = f'life virtue #{virtues_counter}'

+     virtues_counter += 1

+     return virtue

+ 

+ 

+ def add_bug(bugid, milestone, summary=None, status='NEW', active=True, needinfo=False,

+             needinfo_requestee=None, depends_on=[], last_whiteboard_change=month_old_date,

+             last_bug_sync=month_old_date, **kwargs):

+     """Create a new Bug and return it. Use `**kwargs` for specifying additional attributes not

+     exposed in the Bug constructor.

+     """

+     bug = Bug(bugid=bugid,

+               url=f'http://localhost/testbug_{bugid}',

+               summary=summary or get_zen(),

+               status=status,

+               component=get_virtue(),

+               milestone=milestone,

+               active=active,

+               needinfo=needinfo,

+               needinfo_requestee=needinfo_requestee,

+               last_whiteboard_change=last_whiteboard_change,

+               last_bug_sync=last_bug_sync,

+               depends_on=depends_on)

+     for key, val in kwargs.items():

+         setattr(bug, key, val)

+     return bug

+ 

+ 

+ def add_update(updateid_num, release, bugs, status='testing', karma=0,

+                date_submitted=month_old_date, request=None, title=None, stable_karma=3):

+     """Create a new Update and return it.

+     """

+     updateid = f'FEDORA-101-{updateid_num}'

+     update = Update(updateid=updateid,

+                     release=release,

+                     status=status,

+                     karma=karma,

+                     url=f'http://localhost/testupdate_{updateid}',

+                     date_submitted=date_submitted,

+                     request=request,

+                     title=title,

+                     stable_karma=stable_karma,

+                     bugs=bugs)

+     return update

+ 

+ 

+ def remove_test_data(release_num=101):

+     """Remove fake test data from the database. Everything under Release `release_num` will be

+     DELETED (if present).

+     """

+     release = Release.query.filter_by(number=release_num).one_or_none()

+     if not release:

+         return

+ 

+     # delete all objects in a suitable order

+     for update in release.updates.all():

+         db.session.delete(update)

+     for milestone in release.milestones.all():

+         for bug in milestone.bugs.all():

+             db.session.delete(bug)

+         db.session.delete(milestone)

+     db.session.delete(release)

+     db.session.commit()

+ 

+ 

+ def create_test_data(release_num=101):

+     """Create fake test data in the database. Everything under Release `release_num` will be DELETED

+     (if present) and the Release will be created again and populated with Milestones, Bugs, Updates,

+     etc, so that various BlockerBugs functionality can be easily inspected/tested.

+     """

+     # remove everything that exists under the release

+     remove_test_data(release_num)

+ 

+     # create the release

+     release = Release(number=release_num, active=True)

+     db.session.add(release)

+ 

+     # create milestones

+     beta_milestone = Milestone(release=release,

+                                version='beta',

+                                name=f'{release_num}-beta',

+                                blocker_tracker=10101,

+                                fe_tracker=10102,

+                                active=True,

+                                current=False)

+     db.session.add(beta_milestone)

+     final_milestone = Milestone(release=release,

+                                 version='final',

+                                 name=f'{release_num}-final',

+                                 blocker_tracker=10103,

+                                 fe_tracker=10104,

+                                 active=True,

+                                 current=False)

+     db.session.add(final_milestone)

+ 

+     # create bugs

+     # -- beta bugs

+     bug101 = add_bug(101, beta_milestone, proposed_blocker=True)

+     db.session.add(bug101)

+     bug102 = add_bug(102, beta_milestone, status='ASSIGNED', accepted_blocker=True,

+                      discussion_link='http://localhost/discuss102',

+                      last_whiteboard_change=current_date)

+     db.session.add(bug102)

+     bug103 = add_bug(103, beta_milestone, accepted_0day=True, depends_on=[12, 378])

+     db.session.add(bug103)

+     bug104 = add_bug(104, beta_milestone, accepted_prevrel=True)

+     db.session.add(bug104)

+     bug105 = add_bug(105, beta_milestone, status='POST', proposed_fe=True, depends_on=[463],

+                      last_bug_sync=current_date, last_whiteboard_change=current_date,

+                      needinfo=True, needinfo_requestee='Buddha',

+                      discussion_link='http://localhost/discuss105')

+     db.session.add(bug105)

+     bug106 = add_bug(106, beta_milestone, status='VERIFIED', accepted_fe=True)

+     db.session.add(bug106)

+     bug107 = add_bug(107, beta_milestone, prioritized=True)

+     db.session.add(bug107)

+     bug108 = add_bug(108, beta_milestone, status='MODIFIED', last_bug_sync=current_date,

+                      proposed_blocker=True, accepted_fe=True,

+                      discussion_link='http://localhost/discuss108')

+     bug108.votes = '''

+     {"betablocker": {"-1": ["person1"], "0": [], "+1": ["person2", "person3"]}}

+     '''

+     db.session.add(bug108)

+     # -- final bugs

+     bug200 = add_bug(200, final_milestone, proposed_blocker=True)

+     db.session.add(bug200)

+     bug201 = add_bug(201, final_milestone, status='ASSIGNED', proposed_blocker=True)

+     db.session.add(bug201)

+     # -- special bugs

+     bug900 = add_bug(900, beta_milestone, active=False, status='CLOSED',

+                      summary="This shouldn't show up, because the bug is inactive")

+     db.session.add(bug900)

+ 

+     # create updates

+     # -- updates for beta bugs

+     update1 = add_update(1, release, [bug101])

+     db.session.add(update1)

+     update2 = add_update(2, release, [bug108], request='stable', karma=5)

+     db.session.add(update2)

+     update3 = add_update(3, release, [bug104], status='stable', stable_karma=0)

+     db.session.add(update3)

+     update4 = add_update(4, release, [bug102], status='pending', request='testing',

+                          title='Sunshine tweak update')

+     db.session.add(update4)

+     update5 = add_update(5, release, [bug101], status='pending', date_submitted=month_old_date,

+                          stable_karma=0, karma=-2)

+     db.session.add(update5)

+     update6 = add_update(6, release, [bug106], status='pending')

+     db.session.add(update6)

+     update7 = add_update(7, release, [bug107])

+     db.session.add(update7)

+     # -- updates for final bugs

+     update20 = add_update(20, release, [bug201])

+     db.session.add(update20)

+     # -- special updates

+     update90 = add_update(90, release, [bug101, bug102, bug201], status='pending', request='stable',

+                           title='Update for several bugs and milestones')

+     db.session.add(update90)

+     # save

+     db.session.commit()

file modified
+137 -154
@@ -21,200 +21,183 @@ 

  

  from datetime import datetime

  import logging

+ from typing import Optional, Any, Iterable, Sequence

  

  from fedora.client import ServerError

  from bodhi.client.bindings import BodhiClient

+ import flask_sqlalchemy

  

  from blockerbugs import app

  from blockerbugs.models.update import Update

  from blockerbugs.models.bug import Bug

- 

- bodhi_baseurl = app.config['BODHI_URL']

+ from blockerbugs.models.release import Release

  

  

  class UpdateSync(object):

-     def __init__(self, db, bodhi_interface=None):

+     """The main class for perfoming Update synchronization with Bodhi."""

+ 

+     def __init__(self, db: flask_sqlalchemy.SQLAlchemy, bodhiclient: Optional[BodhiClient] = None

+                  ) -> None:

          self.db = db

-         if bodhi_interface:

-             self.bodhi_interface = bodhi_interface()

-         else:

-             # disable saving session on disk by cache_session=False

-             self.bodhi_interface = BodhiClient(base_url=bodhi_baseurl, cache_session=False)

+         # disable saving session on disk by cache_session=False

+         self.bodhi = bodhiclient or BodhiClient(base_url=app.config['BODHI_URL'],

+                                                 cache_session=False)

          self.log = logging.getLogger('update_sync')

-         self._releases = []  # all known releases

+         self._releases: list[dict[str, Any]] = []

+         """All releases known to Bodhi"""

  

      @property

-     def releases(self):

-         '''All releases known to Bodhi, as retrieved from /releases/ endpoint'''

+     def releases(self) -> list[dict[str, Any]]:

+         """All releases known to Bodhi, as retrieved from /releases/ endpoint"""

          if self._releases:

              # already retrieved

              return self._releases

  

-         self._releases = self.bodhi_interface.get_releases(

-             rows_per_page=100)['releases']

- 

-         self.log.debug('Retrieved %d known releases from Bodhi' %

-             (len(self._releases)))

+         self._releases = self.bodhi.get_releases(rows_per_page=100)['releases']

+         self.log.debug('Retrieved %d known releases from Bodhi', len(self._releases))

          return self._releases

  

-     def extract_information(self, update):

-         updateinfo = {}

-         date_pushed = None

-         if update.date_pushed:

-             date_pushed = datetime.strptime(update.date_pushed,

-                                             '%Y-%m-%d %H:%M:%S')

-         updateinfo['date_pushed_testing'] = None

-         updateinfo['date_pushed_stable'] = None

-         updateinfo['pending'] = False

-         # this will be None if there is no request

-         updateinfo['request'] = update.request

-         if update.status == 'pending':

-             updateinfo['pending'] = True

- 

-             if update.request == 'stable':

-                 updateinfo['status'] = 'stable'

-                 updateinfo['date_pushed_testing'] = date_pushed

-             elif update.request == 'testing':

-                 updateinfo['status'] = 'testing'

-             else:

-                 updateinfo['status'] = 'undefined'

-         else:

-             updateinfo['status'] = str(update.status)

-             if update.status == 'testing':

-                 updateinfo['date_pushed_testing'] = date_pushed

-             elif update.status == 'stable':

-                 updateinfo['date_pushed_stable'] = date_pushed

- 

-         updateinfo['title'] = str(update.title)

-         updateinfo['karma'] = update.karma

-         updateinfo['stable_karma'] = update.stable_karma

-         updateinfo['url'] = update.url

- 

-         updateinfo['date_submitted'] = datetime.strptime(update.date_submitted,

+     def extract_information(self, update: dict) -> dict[str, Any]:

+         """Create a dict with extracted Update information. See the code to learn the dict keyvals.

+ 

+         :param update: the update object as retrieved from Bodhi API

+         """

+         updateinfo: dict[str, Any] = {}

+         updateinfo['updateid'] = update['updateid']

+         updateinfo['status'] = update['status']

+         updateinfo['karma'] = update['karma']

+         updateinfo['url'] = update['url']

+         updateinfo['date_submitted'] = datetime.strptime(update['date_submitted'],

                                                           '%Y-%m-%d %H:%M:%S')

-         updateinfo['bugs'] = []

-         if len(update.bugs) > 0:

-             for buginfo in update.bugs:

-                 updateinfo['bugs'].append(buginfo.bug_id)

+         updateinfo['title'] = update['title']

+         updateinfo['request'] = update['request']

+         updateinfo['stable_karma'] = update['stable_karma']

+         updateinfo['bugs'] = [buginfo['bug_id'] for buginfo in update['bugs']]

  

          return updateinfo

  

-     def get_update(self, envr):

-         updates = self.bodhi_interface.query(package=envr)

-         return self.extract_information(updates['updates'][0])

- 

-     def is_watched_bug(self, bugnum):

-         watched_bug = Bug.query.filter_by(bugid=bugnum).first()

-         if watched_bug:

-             return True

-         else:

-             return False

- 

-     def clean_updates(self, updates, relid):

-         """Remove updates for this release which are no longer related

-         to any blocker or freeze exception bug from the database.

+     def clean_updates(self, updateids: Iterable[str], release: Release) -> None:

+         """Remove all updates from the database which are related to a particular release and are

+         not listed among ``updateids``.

          """

-         query_updates = Update.query.filter(

-             Update.release_id == relid,

-         ).all()

-         db_updates = set(update.title for update in query_updates)

-         bodhi_updates = set(update['title'] for update in updates)

-         unneeded_updates = db_updates.difference(bodhi_updates)

- 

-         for update in query_updates:

-             if update.title in unneeded_updates:

-                 self.log.debug("Removing no longer relevant update %s" %

-                                update.title)

+         db_updates: list[Update] = Update.query.filter_by(release=release).all()

+         db_updateids = set(update.updateid for update in db_updates)

+         unneeded_updateids = db_updateids.difference(updateids)

+ 

+         for update in db_updates:

+             if update.updateid in unneeded_updateids:

+                 self.log.debug("Removing no longer relevant %r", update)

                  self.db.session.delete(update)

-                 self.db.session.commit()

+         self.db.session.commit()

  

-     def search_updates(self, bugids, release_num):

-         # not all releases exist all the time (before branching, before bodhi

-         # activation point, etc), so drop those which Bodhi doesn't currently

-         # know of

-         known_releases = [rel['name'].lower() for rel in self.releases]

+     def search_updates(self, bugids: Sequence[int], release_num: int) -> list[dict[str, Any]]:

+         """Find all Bodhi updates in a particular release which fix one of the bugs specified.

+ 

+         :param bugids: Bugzilla ticket numbers

+         :param release_num: the version of a release to query

+         :return: a list of update info dictionaries, as provided by ``extract_information()``

+         """

          query_releases = [

-             'f%d' % release_num,   # standard repo

+             'f%d' % release_num,   # rpms

              'f%df' % release_num,  # flatpaks

-             'f%dm' % release_num,  # modularity

+             'f%dm' % release_num,  # modules

+             'f%dc' % release_num,  # containers

          ]

+         # not all releases exist all the time (before branching, before Bodhi activation point,

+         # etc), so drop those which Bodhi doesn't currently know of

+         known_releases = [rel['name'].lower() for rel in self.releases]

          for rel in query_releases.copy():

              if rel not in known_releases:

-                 self.log.warning("Release %s not found in Bodhi (might be "

-                     "normal depending on release life cycle)" % rel)

+                 self.log.debug("Release %s not found in Bodhi (might be normal depending on the "

+                                "release life cycle)", rel)

                  query_releases.remove(rel)

- 

-         queries_data = dict(

-                 bugs=[str(bug_id) for bug_id in bugids],

-                 release=query_releases,

-                 limit=100,

-         )

-         result = self.bodhi_interface.query(**queries_data)

- 

-         if u'status' in result.keys():

-             raise ServerError('', 200, result['errors'][0].description)

- 

-         updates = {}

-         for update in result.updates:

-             updates[update.title] = update

- 

-         while result.page < result.pages:

-             result = self.bodhi_interface.query(page=result.page + 1,

-                                                 **queries_data)

-             for update in result.updates:

-                 updates[update.title] = update

- 

-         updates = updates.values()  # updates without duplicates

-         self.log.info('found %d updates from bodhi for %d bugs in f%d' %

-                       (len(updates), len(bugids), release_num))

+         if not query_releases:

+             self.log.warning("No releases related to F%d found in Bodhi! Nothing to query.",

+                              release_num)

+             return []

+ 

+         queries_data = {

+             'bugs': [str(bug_id) for bug_id in bugids],

+             'release': query_releases,

+             'limit': 100,

+             'status': ['pending', 'testing', 'stable'],

+         }

+         updates_dict = {}

+         # Bodhi counts pages from 1

+         page = pages = 1

+         while page <= pages:

+             result = self.bodhi.query(page=page, **queries_data)

+ 

+             if 'status' in result:

+                 raise ServerError('', 400, result['errors'][0]['description'])

+ 

+             for update in result['updates']:

+                 assert update['release']['version'] == str(release_num)

+                 assert update['status'] in ['pending', 'testing', 'stable']

+                 updates_dict[update['updateid']] = update

+ 

+             page += 1

+             pages = result['pages']

+ 

+         updates = updates_dict.values()  # updates without duplicates

+         self.log.info('Found %d updates in Bodhi for %d bugs in release F%d', len(updates),

+                       len(bugids), release_num)

          return [self.extract_information(update) for update in updates]

  

-     def get_release_bugs(self, release):

+     def get_release_bugs(self, release: Release) -> list[Bug]:

+         """Get all open proposed/accepted bugs related to a certain release (i.e. to all its

+         active milestones).

+         """

          buglist = []

-         for milestone in release.milestones:

-             buglist.extend(milestone.bugs.filter_by(active=True).all())

+         for milestone in release.milestones.filter_by(active=True):  # type: ignore[attr-defined]

+             buglist.extend(

+                 milestone.bugs.filter(  # type: ignore[attr-defined]

+                     Bug.active == True,  # noqa: E712

+                     Bug.is_proposed_accepted == True,

+                 ).all()

+             )

+ 

          return buglist

  

-     def sync_bug_updates(self, release, bugs):

-         starttime = datetime.utcnow()

-         bugs_ids = [bug.bugid for bug in bugs]

-         self.log.debug('searching for updates for bugs %s' % str(bugs_ids))

-         try:

-             updates = self.search_updates(bugs_ids, release.number)

-         except ServerError as ex:

-             self.log.error(

-                 'f{r.number} sync updates failed: {e.code} {e.msg}'.format(

-                     e=ex, r=release))

-             return

+     def sync_updates(self, release: Release) -> None:

+         """Synchronize all updates for a particular release. That means pulling new updates from

+         Bodhi (related to all bugs which we track in the release), and removing no-longer-relevant

+         updates from the database.

+         """

+         self.log.info('Syncing updates for release F%d ...', release.number)

+ 

+         bugs = self.get_release_bugs(release)

+         self.log.debug('Found %d relevant bugs in release F%d', len(bugs), release.number)

+ 

+         updateinfos = []