diff --git a/ietf/name/admin.py b/ietf/name/admin.py index 3924b7910..33010654c 100644 --- a/ietf/name/admin.py +++ b/ietf/name/admin.py @@ -1,7 +1,9 @@ +# Copyright The IETF Trust 2016-2019, All Rights Reserved from django.contrib import admin from ietf.name.models import ( - AgendaTypeName, BallotPositionName, ConstraintName, ContinentName, CountryName, DBTemplateTypeName, + AgendaTypeName, BallotPositionName, ConstraintName, ContinentName, CountryName, + DBTemplateTypeName, DocRelationshipName, DocReminderTypeName, DocTagName, DocTypeName, DraftSubmissionStateName, FeedbackTypeName, FormalLanguageName, GroupMilestoneStateName, GroupStateName, GroupTypeName, ImportantDateName, IntendedStdLevelName, IprDisclosureStateName, IprEventTypeName, @@ -9,7 +11,7 @@ from ietf.name.models import ( LiaisonStatementState, LiaisonStatementTagName, MeetingTypeName, NomineePositionStateName, ReviewRequestStateName, ReviewResultName, ReviewTypeName, RoleName, RoomResourceName, SessionStatusName, StdLevelName, StreamName, TimeSlotTypeName, TopicAudienceName, - DocUrlTagName, ReviewAssignmentStateName) + DocUrlTagName, ReviewAssignmentStateName, ReviewerQueuePolicyName) from ietf.stats.models import CountryAlias @@ -70,6 +72,7 @@ admin.site.register(NomineePositionStateName, NameAdmin) admin.site.register(ReviewRequestStateName, NameAdmin) admin.site.register(ReviewAssignmentStateName, NameAdmin) admin.site.register(ReviewResultName, NameAdmin) +admin.site.register(ReviewerQueuePolicyName, NameAdmin) admin.site.register(ReviewTypeName, NameAdmin) admin.site.register(RoleName, NameAdmin) admin.site.register(RoomResourceName, NameAdmin) diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index e0d272bb1..2f193eda7 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -10971,6 +10971,26 @@ "model": "name.reviewresultname", "pk": "serious-issues" }, + { + "fields": { + "desc": "", + "name": "Rotate alphabetically", + "order": 1, + "used": true + }, + "model": "name.reviewerqueuepolicyname", + "pk": "RotateAlphabetically" + }, + { + "fields": { + "desc": "", + "name": "Least recently used", + "order": 2, + "used": true + }, + "model": "name.reviewerqueuepolicyname", + "pk": "LeastRecentlyUsed" + }, { "fields": { "desc": "", diff --git a/ietf/name/migrations/0008_reviewerqueuepolicyname.py b/ietf/name/migrations/0008_reviewerqueuepolicyname.py new file mode 100644 index 000000000..7e9804245 --- /dev/null +++ b/ietf/name/migrations/0008_reviewerqueuepolicyname.py @@ -0,0 +1,38 @@ +# Copyright The IETF Trust 2019, All Rights Reserved +# -*- coding: utf-8 -*- +# Generated by Django 1.11.23 on 2019-11-18 08:35 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + def forward(apps, schema_editor): + ReviewerQueuePolicyName = apps.get_model('name', 'ReviewerQueuePolicyName') + ReviewerQueuePolicyName.objects.create(slug='RotateAlphabetically', name='Rotate alphabetically') + ReviewerQueuePolicyName.objects.create(slug='LeastRecentlyUsed', name='Least recently used') + + def reverse(self, apps): + pass + + dependencies = [ + ('name', '0007_fix_m2m_slug_id_length'), + ] + + operations = [ + migrations.CreateModel( + name='ReviewerQueuePolicyName', + fields=[ + ('slug', models.CharField(max_length=32, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255)), + ('desc', models.TextField(blank=True)), + ('used', models.BooleanField(default=True)), + ('order', models.IntegerField(default=0)), + ], + options={ + 'ordering': ['order', 'name'], + 'abstract': False, + }, + ), + migrations.RunPython(forward, reverse), + ] diff --git a/ietf/name/models.py b/ietf/name/models.py index 453b7042e..7b3c364ac 100644 --- a/ietf/name/models.py +++ b/ietf/name/models.py @@ -110,6 +110,8 @@ class ReviewResultName(NameModel): """Almost ready, Has issues, Has nits, Not Ready, On the right track, Ready, Ready with issues, Ready with nits, Serious Issues""" +class ReviewerQueuePolicyName(NameModel): + """RotateAlphabetically, LeastRecentlyUsed""" class TopicAudienceName(NameModel): """General, Nominee, Nomcom Member""" class ContinentName(NameModel): diff --git a/ietf/name/resources.py b/ietf/name/resources.py index 83f8d0b6f..9054ccbff 100644 --- a/ietf/name/resources.py +++ b/ietf/name/resources.py @@ -1,3 +1,4 @@ +# Copyright The IETF Trust 2016-2019, All Rights Reserved # Autogenerated by the makeresources management command 2015-08-27 11:01 PDT from ietf.api import ModelResource from ietf.api import ToOneField # pyflakes:ignore @@ -7,16 +8,24 @@ from tastypie.cache import SimpleCache from ietf import api -from ietf.name.models import ( AgendaTypeName, BallotPositionName, ConstraintName, - ContinentName, CountryName, DBTemplateTypeName, DocRelationshipName, DocReminderTypeName, - DocTagName, DocTypeName, DocUrlTagName, DraftSubmissionStateName, FeedbackTypeName, - FormalLanguageName, GroupMilestoneStateName, GroupStateName, GroupTypeName, - ImportantDateName, IntendedStdLevelName, IprDisclosureStateName, IprEventTypeName, - IprLicenseTypeName, LiaisonStatementEventTypeName, LiaisonStatementPurposeName, - LiaisonStatementState, LiaisonStatementTagName, MeetingTypeName, NomineePositionStateName, - ReviewAssignmentStateName, ReviewRequestStateName, ReviewResultName, ReviewTypeName, - RoleName, RoomResourceName, SessionStatusName, StdLevelName, StreamName, TimeSlotTypeName, - TopicAudienceName, ) +from ietf.name.models import (AgendaTypeName, BallotPositionName, ConstraintName, + ContinentName, CountryName, DBTemplateTypeName, DocRelationshipName, + DocReminderTypeName, + DocTagName, DocTypeName, DocUrlTagName, DraftSubmissionStateName, + FeedbackTypeName, + FormalLanguageName, GroupMilestoneStateName, GroupStateName, + GroupTypeName, + ImportantDateName, IntendedStdLevelName, IprDisclosureStateName, + IprEventTypeName, + IprLicenseTypeName, LiaisonStatementEventTypeName, + LiaisonStatementPurposeName, + LiaisonStatementState, LiaisonStatementTagName, MeetingTypeName, + NomineePositionStateName, + ReviewAssignmentStateName, ReviewRequestStateName, ReviewResultName, + ReviewTypeName, + RoleName, RoomResourceName, SessionStatusName, StdLevelName, + StreamName, TimeSlotTypeName, + TopicAudienceName, ReviewerQueuePolicyName) class TimeSlotTypeNameResource(ModelResource): class Meta: @@ -471,6 +480,19 @@ class ReviewResultNameResource(ModelResource): } api.name.register(ReviewResultNameResource()) +class ReviewerQueuePolicyNameResource(ModelResource): + class Meta: + cache = SimpleCache() + queryset = ReviewerQueuePolicyName.objects.all() + filtering = { + "slug": ALL, + "name": ALL, + "desc": ALL, + "used": ALL, + "order": ALL, + } +api.name.register(ReviewerQueuePolicyNameResource()) + class TopicAudienceNameResource(ModelResource): class Meta: cache = SimpleCache() diff --git a/ietf/review/factories.py b/ietf/review/factories.py index 7e7a2480f..1812ae5b0 100644 --- a/ietf/review/factories.py +++ b/ietf/review/factories.py @@ -1,14 +1,17 @@ +# Copyright The IETF Trust 2016-2019, All Rights Reserved import factory import datetime from ietf.review.models import ReviewTeamSettings, ReviewRequest, ReviewAssignment, ReviewerSettings from ietf.name.models import ReviewTypeName, ReviewResultName + class ReviewTeamSettingsFactory(factory.DjangoModelFactory): class Meta: model = ReviewTeamSettings group = factory.SubFactory('ietf.group.factories.GroupFactory',type_id='review') + reviewer_queue_policy_id = 'RotateAlphabetically' @factory.post_generation def review_types(obj, create, extracted, **kwargs): diff --git a/ietf/review/migrations/0021_reviewteamsettings_reviewer_queue_policy.py b/ietf/review/migrations/0021_reviewteamsettings_reviewer_queue_policy.py new file mode 100644 index 000000000..d8d09fe74 --- /dev/null +++ b/ietf/review/migrations/0021_reviewteamsettings_reviewer_queue_policy.py @@ -0,0 +1,23 @@ +# Copyright The IETF Trust 2019, All Rights Reserved +# -*- coding: utf-8 -*- +# Generated by Django 1.11.23 on 2019-11-18 08:50 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('name', '0008_reviewerqueuepolicyname'), + ('review', '0020_add_request_assignment_next'), + ] + + operations = [ + migrations.AddField( + model_name='reviewteamsettings', + name='reviewer_queue_policy', + field=models.ForeignKey(default='RotateAlphabetically', on_delete=django.db.models.deletion.PROTECT, to='name.ReviewerQueuePolicyName'), + ), + ] diff --git a/ietf/review/models.py b/ietf/review/models.py index 522ea08a8..cdb9c7106 100644 --- a/ietf/review/models.py +++ b/ietf/review/models.py @@ -14,7 +14,8 @@ from django.utils.encoding import python_2_unicode_compatible from ietf.doc.models import Document from ietf.group.models import Group from ietf.person.models import Person, Email -from ietf.name.models import ReviewTypeName, ReviewRequestStateName, ReviewResultName, ReviewAssignmentStateName +from ietf.name.models import ReviewTypeName, ReviewRequestStateName, ReviewResultName, \ + ReviewAssignmentStateName, ReviewerQueuePolicyName from ietf.utils.validators import validate_regular_expression_string from ietf.utils.models import ForeignKey, OneToOneField @@ -184,6 +185,7 @@ class ReviewTeamSettings(models.Model): """Holds configuration specific to groups that are review teams""" group = OneToOneField(Group) autosuggest = models.BooleanField(default=True, verbose_name="Automatically suggest possible review requests") + reviewer_queue_policy = models.ForeignKey(ReviewerQueuePolicyName, default='RotateAlphabetically', on_delete=models.PROTECT) review_types = models.ManyToManyField(ReviewTypeName, default=get_default_review_types) review_results = models.ManyToManyField(ReviewResultName, default=get_default_review_results, related_name='reviewteamsettings_review_results_set') notify_ad_when = models.ManyToManyField(ReviewResultName, related_name='reviewteamsettings_notify_ad_set', blank=True) diff --git a/ietf/review/policies.py b/ietf/review/policies.py index e89c5cd10..cffde3e51 100644 --- a/ietf/review/policies.py +++ b/ietf/review/policies.py @@ -13,7 +13,7 @@ from ietf.group.models import Role from ietf.person.models import Person import debug # pyflakes:ignore from ietf.review.models import NextReviewerInTeam, ReviewerSettings, ReviewWish, ReviewRequest, \ - ReviewAssignment + ReviewAssignment, ReviewTeamSettings from ietf.review.utils import (current_unavailable_periods_for_reviewers, days_needed_to_fulfill_min_interval_for_reviewers, get_default_filter_re, @@ -28,7 +28,17 @@ Terminology used here should match terminology used in that document. def get_reviewer_queue_policy(team): - return RotateAlphabeticallyReviewerQueuePolicy(team) + try: + settings = ReviewTeamSettings.objects.get(group=team) + except ReviewTeamSettings.DoesNotExist: + raise ValueError('Request for a reviewer queue policy for team {} ' + 'which has no ReviewTeamSettings'.format(team)) + try: + policy = QUEUE_POLICY_NAME_MAPPING[settings.reviewer_queue_policy.slug] + except KeyError: + raise ValueError('Team {} has unknown reviewer queue policy: ' + '{}'.format(team, settings.reviewer_queue_policy.slug)) + return policy(team) class AbstractReviewerQueuePolicy: @@ -406,3 +416,8 @@ class LeastRecentlyUsedReviewerQueuePolicy(AbstractReviewerQueuePolicy): return rotation_list + +QUEUE_POLICY_NAME_MAPPING = { + 'RotateAlphabetically': RotateAlphabeticallyReviewerQueuePolicy, + 'LeastRecentlyUsed': LeastRecentlyUsedReviewerQueuePolicy, +} diff --git a/ietf/review/test_policies.py b/ietf/review/test_policies.py index b33921497..24d077db5 100644 --- a/ietf/review/test_policies.py +++ b/ietf/review/test_policies.py @@ -3,16 +3,38 @@ from ietf.doc.factories import WgDraftFactory, IndividualDraftFactory from ietf.group.factories import ReviewTeamFactory from ietf.group.models import Group, Role +from ietf.name.models import ReviewerQueuePolicyName from ietf.person.fields import PersonEmailChoiceField from ietf.person.models import Email from ietf.review.factories import ReviewAssignmentFactory, ReviewRequestFactory -from ietf.review.models import ReviewerSettings, NextReviewerInTeam, UnavailablePeriod, ReviewWish -from ietf.review.policies import get_reviewer_queue_policy, AssignmentOrderResolver, \ - LeastRecentlyUsedReviewerQueuePolicy, RotateAlphabeticallyReviewerQueuePolicy +from ietf.review.models import ReviewerSettings, NextReviewerInTeam, UnavailablePeriod, ReviewWish, \ + ReviewTeamSettings +from ietf.review.policies import (AssignmentOrderResolver, LeastRecentlyUsedReviewerQueuePolicy, + RotateAlphabeticallyReviewerQueuePolicy, + get_reviewer_queue_policy) from ietf.utils.test_data import create_person from ietf.utils.test_utils import TestCase +class GetReviewerQueuePolicyTest(TestCase): + def test_valid_policy(self): + team = ReviewTeamFactory(acronym="rotationteam", name="Review Team", list_email="rotationteam@ietf.org", parent=Group.objects.get(acronym="farfut"), settings__reviewer_queue_policy_id='LeastRecentlyUsed') + policy = get_reviewer_queue_policy(team) + self.assertEqual(policy.__class__, LeastRecentlyUsedReviewerQueuePolicy) + + def test_missing_settings(self): + team = ReviewTeamFactory(acronym="rotationteam", name="Review Team", list_email="rotationteam@ietf.org", parent=Group.objects.get(acronym="farfut")) + ReviewTeamSettings.objects.all().delete() + with self.assertRaises(ValueError): + get_reviewer_queue_policy(team) + + def test_invalid_policy_name(self): + ReviewerQueuePolicyName.objects.create(slug='invalid') + team = ReviewTeamFactory(acronym="rotationteam", name="Review Team", list_email="rotationteam@ietf.org", parent=Group.objects.get(acronym="farfut"), settings__reviewer_queue_policy_id='invalid') + with self.assertRaises(ValueError): + get_reviewer_queue_policy(team) + + class RotateAlphabeticallyReviewerQueuePolicyTest(TestCase): """ These tests also cover the common behaviour in RotateAlphabeticallyReviewerQueuePolicy, @@ -63,7 +85,7 @@ class RotateAlphabeticallyReviewerQueuePolicyTest(TestCase): def test_setup_reviewer_field(self): team = ReviewTeamFactory(acronym="rotationteam", name="Review Team", list_email="rotationteam@ietf.org", parent=Group.objects.get(acronym="farfut")) - policy = get_reviewer_queue_policy(team) + policy = RotateAlphabeticallyReviewerQueuePolicy(team) reviewer_0 = create_person(team, "reviewer", name="Test Reviewer-0", username="testreviewer0") reviewer_1 = create_person(team, "reviewer", name="Test Reviewer-1", username="testreviewer1") review_req = ReviewRequestFactory(team=team, type_id='early') @@ -80,7 +102,7 @@ class RotateAlphabeticallyReviewerQueuePolicyTest(TestCase): def test_recommended_assignment_order(self): team = ReviewTeamFactory(acronym="rotationteam", name="Review Team", list_email="rotationteam@ietf.org", parent=Group.objects.get(acronym="farfut")) - policy = get_reviewer_queue_policy(team) + policy = RotateAlphabeticallyReviewerQueuePolicy(team) reviewer_high = create_person(team, "reviewer", name="Test Reviewer-1-high", username="testreviewerhigh") reviewer_low = create_person(team, "reviewer", name="Test Reviewer-0-low", username="testreviewerlow") @@ -100,7 +122,7 @@ class RotateAlphabeticallyReviewerQueuePolicyTest(TestCase): def test_update_policy_state_for_assignment(self): team = ReviewTeamFactory(acronym="rotationteam", name="Review Team", list_email="rotationteam@ietf.org", parent=Group.objects.get(acronym="farfut")) - policy = get_reviewer_queue_policy(team) + policy = RotateAlphabeticallyReviewerQueuePolicy(team) # make a bunch of reviewers reviewers = [