diff --git a/requirements_dev.txt b/requirements_dev.txt index e589676c..47bb70ca 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -6,6 +6,7 @@ django-debug-toolbar==4.2.0 factory-boy==3.2.1 Faker==13.15.1 filelock==3.7.1 +freezegun==1.5.1 identify==2.5.2 mdgen==0.1.10 nodeenv==1.7.0 diff --git a/reviewers/migrations/0012_approvalqueue.py b/reviewers/migrations/0012_approvalqueue.py new file mode 100644 index 00000000..bacf49ca --- /dev/null +++ b/reviewers/migrations/0012_approvalqueue.py @@ -0,0 +1,52 @@ +# Generated by Django 4.2.11 on 2024-08-23 09:42 + +from django.db import migrations, models +from django.db.models import Count +import django.db.models.deletion + + +def populate_approval_queue(apps, schema_editor): + ApprovalActivity = apps.get_model('reviewers', 'ApprovalActivity') + ApprovalQueue = apps.get_model('reviewers', 'ApprovalQueue') + processed_extensions = set() + STATUS_CHANGE_TYPES = {"AWR": 0x02, "AWC": 0x01, "APR": 0x00} + activity_counts = {} + for row in ApprovalActivity.objects.all().values('extension__pk').annotate(count=Count('id')).order_by(): + activity_counts[row['extension__pk']] = row['count'] + for activity in ApprovalActivity.objects.order_by('-id').all(): + extension = activity.extension + if extension in processed_extensions: + continue + if activity.type in STATUS_CHANGE_TYPES: + activity_count = activity_counts[extension.pk] + timestamp = int(activity.date_created.timestamp()) + sortkey = (STATUS_CHANGE_TYPES[activity.type] << 32) | timestamp + ApprovalQueue.objects.create( + activity_count=activity_count, + extension=extension, + latest_activity=activity, + sortkey=sortkey, + ) + processed_extensions.add(extension) + + +class Migration(migrations.Migration): + + dependencies = [ + ('extensions', '0042_platform_name'), + ('reviewers', '0011_alter_approvalactivity_user'), + ] + + operations = [ + migrations.CreateModel( + name='ApprovalQueue', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('activity_count', models.PositiveIntegerField()), + ('sortkey', models.BigIntegerField()), + ('extension', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='extensions.extension')), + ('latest_activity', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='reviewers.approvalactivity')), + ], + ), + migrations.RunPython(populate_approval_queue, migrations.RunPython.noop), + ] diff --git a/reviewers/models.py b/reviewers/models.py index b4f3dd7b..29c008e5 100644 --- a/reviewers/models.py +++ b/reviewers/models.py @@ -28,6 +28,8 @@ class CannedResponse(CreatedModifiedMixin, models.Model): class ApprovalActivity(CreatedModifiedMixin, RecordDeletionMixin, models.Model): + STATUS_CHANGE_TYPES = {"AWR": 0x02, "AWC": 0x01, "APR": 0x00} + class ActivityType(models.TextChoices): COMMENT = "COM", _("Comment") APPROVED = "APR", _("Approved") @@ -53,3 +55,37 @@ class ApprovalActivity(CreatedModifiedMixin, RecordDeletionMixin, models.Model): def __str__(self): return f"{self.extension.name}: {self.get_type_display()}" + + @property + def queue_sortkey(self): + """Sorting by moderation status and latest status change timestamp. + + The queue is ordered by status: first "awaiting review', then "awaiting changes", then + "approved". + Within each group items with most recent status change are sorted to the top. + + Integer timestamp representation takes 4 bytes, the resulting bigint is composed of + 0x000000SSTTTTTTTT, where SS byte represents the status change type, and TT bytes represent + timestamp bytes. + """ + timestamp = int(self.date_created.timestamp()) + return (self.STATUS_CHANGE_TYPES[self.type] << 32) | timestamp + + +class ApprovalQueue(models.Model): + activity_count = models.PositiveIntegerField() + extension = models.OneToOneField( + 'extensions.Extension', + on_delete=models.CASCADE, + ) + latest_activity = models.ForeignKey( + ApprovalActivity, + # we don't delete activity yet, if we start this needs to be updated via a pre_delete signal + on_delete=models.PROTECT, + ) + sortkey = models.BigIntegerField() + + class Meta: + indexes = [ + models.Index(fields=['sortkey']), + ] diff --git a/reviewers/signals.py b/reviewers/signals.py index 2311e524..9ed8ec91 100644 --- a/reviewers/signals.py +++ b/reviewers/signals.py @@ -4,7 +4,7 @@ from django.db.models.signals import post_save, pre_delete from django.dispatch import receiver from constants.activity import Flag, Verb -from reviewers.models import ApprovalActivity +from reviewers.models import ApprovalActivity, ApprovalQueue @receiver(post_save, sender=ApprovalActivity) @@ -40,6 +40,35 @@ def _create_action_from_review_and_follow( ) +@receiver(post_save, sender=ApprovalActivity) +def _update_approval_queue( + sender: object, + instance: ApprovalActivity, + created: bool, + raw: bool, + **kwargs: object, +) -> None: + if raw: + return + if not created: + return + + activity_count = ApprovalActivity.objects.filter(extension=instance.extension).count() + if instance.type in ApprovalActivity.STATUS_CHANGE_TYPES: + ApprovalQueue.objects.update_or_create( + extension=instance.extension, + defaults={ + 'activity_count': activity_count, + 'latest_activity': instance, + 'sortkey': instance.queue_sortkey, + }, + ) + else: + if item := ApprovalQueue.objects.filter(extension=instance.extension).first(): + item.activity_count = activity_count + item.save(update_fields={'activity_count'}) + + @receiver(pre_delete, sender=ApprovalActivity) def _log_deletion(sender: object, instance: ApprovalActivity, **kwargs: object) -> None: instance.record_deletion() diff --git a/reviewers/templates/reviewers/components/review_list_item.html b/reviewers/templates/reviewers/components/review_list_item.html index 5b30c9df..0a2998dd 100644 --- a/reviewers/templates/reviewers/components/review_list_item.html +++ b/reviewers/templates/reviewers/components/review_list_item.html @@ -22,20 +22,20 @@ {{ extension.date_created|naturaltime_compact }} - - {{ stats.last_activity.date_created|naturaltime_compact }} + + {{ item.latest_activity.date_created|naturaltime_compact }} {% include "files/components/scan_details_flag.html" with suspicious_files=extension.suspicious_files %} - {{ stats.count }} + {{ item.activity_count }} - {% with last_type=stats.last_type_display|default:"Awaiting Review" %} - {% include "common/components/status.html" with label=last_type slug=last_type|slugify object=extension classes="d-block" icon=True %} + {% with latest_type=item.latest_activity.get_type_display %} + {% include "common/components/status.html" with label=latest_type slug=latest_type|slugify object=extension classes="d-block" icon=True %} {% endwith %} diff --git a/reviewers/templates/reviewers/extensions_review_list.html b/reviewers/templates/reviewers/extensions_review_list.html index 13b6a66f..6dd75034 100644 --- a/reviewers/templates/reviewers/extensions_review_list.html +++ b/reviewers/templates/reviewers/extensions_review_list.html @@ -36,9 +36,9 @@ - {% for stats in object_list %} - {% with extension=stats.extension %} - {% include 'reviewers/components/review_list_item.html' with extension=extension stats=stats %} + {% for item in object_list %} + {% with extension=item.extension %} + {% include 'reviewers/components/review_list_item.html' with extension=extension item=item %} {% endwith %} {% endfor %} diff --git a/reviewers/tests/test_views.py b/reviewers/tests/test_views.py index d2028a1d..fa5f4a70 100644 --- a/reviewers/tests/test_views.py +++ b/reviewers/tests/test_views.py @@ -1,7 +1,11 @@ +from datetime import datetime, timedelta, timezone + from django.test import TestCase from django.shortcuts import reverse +from freezegun import freeze_time from common.tests.factories.extensions import create_version +from common.tests.factories.users import UserFactory from files.models import File from reviewers.models import ApprovalActivity @@ -13,7 +17,7 @@ class CommentsViewTest(TestCase): version = create_version(status=File.STATUSES.AWAITING_REVIEW) self.default_version = version ApprovalActivity( - type=ApprovalActivity.ActivityType.COMMENT, + type=ApprovalActivity.ActivityType.AWAITING_REVIEW, user=version.files.first().user, extension=version.extension, message='test comment', @@ -40,3 +44,97 @@ class CommentsViewTest(TestCase): # Display only previews and version that are 'pending review' # Approval requires manager role + + +class QueueOrderTest(TestCase): + fixtures = ['licenses'] + + def test_order(self): + extensions = [] + user = UserFactory() + for i in range(5): + version = create_version(status=File.STATUSES.AWAITING_REVIEW) + extension = version.extension + extensions.append(extension) + + # to control the distance between timestamps + starting_datetime = datetime(2020, 12, 31, 23, 2, 3, tzinfo=timezone.utc) + delta_s = 0 + with freeze_time(starting_datetime + timedelta(seconds=delta_s)): + ApprovalActivity( + type=ApprovalActivity.ActivityType.AWAITING_REVIEW, + user=user, + extension=extensions[0], + message='test comment', + ).save() + + delta_s += 1 + with freeze_time(starting_datetime + timedelta(seconds=delta_s)): + ApprovalActivity( + type=ApprovalActivity.ActivityType.AWAITING_CHANGES, + user=user, + extension=extensions[1], + message='test comment', + ).save() + + delta_s += 1 + with freeze_time(starting_datetime + timedelta(seconds=delta_s)): + ApprovalActivity( + type=ApprovalActivity.ActivityType.AWAITING_CHANGES, + user=user, + extension=extensions[2], + message='test comment', + ).save() + + apr = None + delta_s += 1 + with freeze_time(starting_datetime + timedelta(seconds=delta_s)): + apr = ApprovalActivity( + date_created=starting_datetime + timedelta(seconds=delta_s), + type=ApprovalActivity.ActivityType.APPROVED, + user=user, + extension=extensions[3], + message='test comment', + ) + apr.save() + delta_s += 1 + with freeze_time(starting_datetime + timedelta(seconds=delta_s)): + ApprovalActivity( + date_created=starting_datetime + timedelta(seconds=delta_s), + type=ApprovalActivity.ActivityType.COMMENT, + user=user, + extension=extensions[3], + message='test comment', + ).save() + + delta_s += 1 + with freeze_time(starting_datetime + timedelta(seconds=delta_s)): + ApprovalActivity( + date_created=starting_datetime + timedelta(seconds=delta_s), + type=ApprovalActivity.ActivityType.COMMENT, + user=user, + extension=extensions[4], + message='test comment', + ).save() + + response = self.client.get(reverse('reviewers:approval-queue')) + queue = response.context['object_list'] + + # extensions[4] doesn't have meaningful activity + self.assertEqual(len(queue), 4) + + # ordered by category + self.assertEqual(queue[0].extension, extensions[0]) + # and within category by recent timestamp + self.assertEqual(queue[1].extension, extensions[2]) + self.assertEqual(queue[2].extension, extensions[1]) + self.assertEqual(queue[3].extension, extensions[3]) + + # counts are correct + self.assertEqual(queue[0].activity_count, 1) + self.assertEqual(queue[1].activity_count, 1) + self.assertEqual(queue[2].activity_count, 1) + self.assertEqual(queue[3].activity_count, 2) + + # latest_activity doesn't account for non-meaningful activity + self.assertEqual(queue[3].latest_activity, apr) diff --git a/reviewers/views.py b/reviewers/views.py index a119e363..b11a79a8 100644 --- a/reviewers/views.py +++ b/reviewers/views.py @@ -1,4 +1,3 @@ -from collections import defaultdict import logging from actstream.actions import follow, is_following, unfollow @@ -11,62 +10,30 @@ import django.forms from constants.activity import Flag from extensions.models import Extension from reviewers.forms import CommentForm -from reviewers.models import ApprovalActivity +from reviewers.models import ApprovalActivity, ApprovalQueue log = logging.getLogger(__name__) -# the ordering of this list determines the order of rows in approval queue listing -STATUS_CHANGE_TYPES = [ - ApprovalActivity.ActivityType.AWAITING_REVIEW, - ApprovalActivity.ActivityType.AWAITING_CHANGES, - ApprovalActivity.ActivityType.APPROVED, -] - class ApprovalQueueView(ListView): model = Extension paginate_by = 50 def get_queryset(self): - qs = ApprovalActivity.objects.prefetch_related( - 'extension', - 'extension__authors', - 'extension__icon', - 'extension__versions', - 'extension__versions__files', - 'extension__versions__files__validation', - ).order_by('-id') - by_extension = {} - by_date_created = [] - for item in qs: - extension = item.extension - stats = by_extension.get(extension, None) - if not stats: - # this check guarantees that we add a record only once per extension, - # and iterating over qs we get by_date_created also ordered by item.date_created - stats = { - 'count': 0, - 'extension': extension, - 'last_activity': None, - 'last_type_display': None, - 'type': None, - } - by_extension[extension] = stats - by_date_created.append(stats) - stats['count'] += 1 - if not stats.get('last_activity', None): - stats['last_activity'] = item - if not stats.get('last_type_display', None) and item.type in STATUS_CHANGE_TYPES: - stats['last_type_display'] = item.get_type_display() - stats['type'] = item.type - groupped_by_type = defaultdict(list) - for stats in by_date_created: - type = stats['type'] or ApprovalActivity.ActivityType.AWAITING_REVIEW - groupped_by_type[type].append(stats) - result = [] - for type in STATUS_CHANGE_TYPES: - result.extend(groupped_by_type[type]) - return result + return ( + ApprovalQueue.objects.select_related( + 'extension', + 'latest_activity', + ) + .prefetch_related( + 'extension__authors', + 'extension__icon', + 'extension__versions', + 'extension__versions__files', + 'extension__versions__files__validation', + ) + .order_by('-sortkey') + ) template_name = 'reviewers/extensions_review_list.html' @@ -102,7 +69,7 @@ class ExtensionsApprovalDetailView(DetailView): activity.user_is_maintainer = True ctx['review_activity'] = review_activity - ctx['status_change_types'] = STATUS_CHANGE_TYPES + ctx['status_change_types'] = ApprovalActivity.STATUS_CHANGE_TYPES if self.request.user.is_authenticated: initial = {}