diff --git a/pgcommitfest/commitfest/forms.py b/pgcommitfest/commitfest/forms.py index a01b437e..25fa9f9a 100644 --- a/pgcommitfest/commitfest/forms.py +++ b/pgcommitfest/commitfest/forms.py @@ -5,7 +5,14 @@ from django.http import Http404 from .ajax import _archivesAPI -from .models import MailThread, Patch, PatchOnCommitFest, Tag, TargetVersion +from .models import ( + Committer, + MailThread, + Patch, + PatchOnCommitFest, + Tag, + TargetVersion, +) from .widgets import ThreadPickWidget @@ -22,9 +29,12 @@ class CommitFestFilterForm(forms.Form): tag = forms.MultipleChoiceField(required=False, label="Tag (type to search)") author = forms.ChoiceField(required=False, label="Author (type to search)") reviewer = forms.ChoiceField(required=False, label="Reviewer (type to search)") + committer = forms.ChoiceField(required=False, label="Committer") sortkey = forms.IntegerField(required=False) - def __init__(self, data, commitfest=None, *args, **kwargs): + def __init__( + self, data, commitfest=None, *args, include_committer_filter=False, **kwargs + ): super(CommitFestFilterForm, self).__init__(data, *args, **kwargs) self.commitfest = commitfest @@ -79,6 +89,26 @@ def __init__(self, data, commitfest=None, *args, **kwargs): ): self.fields[f].widget.attrs = {"class": "input-medium"} + # The committer filter is only supported on the page of a single + # commitfest, so other pages that share this form don't get it. The + # list of committers in a commitfest is short, so a plain dropdown is + # fine. It's sorted by first name, to match how the names are shown. + if include_committer_filter: + committers = ( + Committer.objects.filter( + patch__patchoncommitfest__commitfest=commitfest + ) + .select_related("user") + .distinct() + .order_by("user__first_name", "user__last_name", "user__username") + ) + self.fields["committer"].choices = [(-1, "* All")] + [ + (c.user_id, c.fullname) for c in committers + ] + self.fields["committer"].widget.attrs = {"class": "input-medium"} + else: + del self.fields["committer"] + # Tags that get their own checkbox buttons as shortcuts (also appear in selectize dropdown) CHECKBOX_TAG_NAMES = ["Bugfix", "Security", "Performance"] diff --git a/pgcommitfest/commitfest/templates/commitfest.html b/pgcommitfest/commitfest/templates/commitfest.html index 2e71784b..2549d2d5 100644 --- a/pgcommitfest/commitfest/templates/commitfest.html +++ b/pgcommitfest/commitfest/templates/commitfest.html @@ -41,7 +41,7 @@

{{p.is_open|yesno:"Active patches,Closed patches"}}

Stats{%if sortkey == 6%}
{%elif sortkey == -6%}
{%endif%} Author Reviewers - Committer + Committer{%if sortkey == 9%}
{%elif sortkey == -9%}
{%endif%} Num cfs{%if sortkey == 3%}
{%elif sortkey == -3%}
{%endif%} Latest mail{%if sortkey == 2%}
{%elif sortkey == -2%}
{%endif%} {%if user.is_staff%} diff --git a/pgcommitfest/commitfest/tests/test_committer_filter.py b/pgcommitfest/commitfest/tests/test_committer_filter.py new file mode 100644 index 00000000..906d5d09 --- /dev/null +++ b/pgcommitfest/commitfest/tests/test_committer_filter.py @@ -0,0 +1,246 @@ +from django.contrib.auth.models import User + +from datetime import datetime + +import pytest + +from pgcommitfest.commitfest.models import Committer, Patch, PatchOnCommitFest, Tag + +# The commitfest views start with SET TRANSACTION ISOLATION LEVEL, which must +# be the first statement in a transaction, so the test can't wrap them in one. +pytestmark = pytest.mark.django_db(transaction=True, serialized_rollback=True) + + +def make_committer(username, first_name, last_name, active=True): + user = User.objects.create_user( + username=username, first_name=first_name, last_name=last_name + ) + return Committer.objects.create(user=user, active=active) + + +def make_patch(cf, name, committer=None, status=PatchOnCommitFest.STATUS_REVIEW): + patch = Patch.objects.create(name=name, committer=committer) + is_open = status in PatchOnCommitFest.OPEN_STATUSES + PatchOnCommitFest.objects.create( + patch=patch, + commitfest=cf, + enterdate=datetime.now(), + leavedate=None if is_open else datetime.now(), + status=status, + ) + return patch + + +def get_patch_names(client, cf, **params): + response = client.get(f"/{cf.id}/", params) + assert response.status_code == 200 + return [p["name"] for p in response.context["patches"]] + + +@pytest.fixture +def cf(commitfests): + # Rendering pages needs the full set of relevant commitfests to exist. + return commitfests["open"] + + +@pytest.fixture +def committers(): + return { + "adams": make_committer("zadams", "Zed", "Adams"), + "young": make_committer("ayoung", "Amy", "Young"), + } + + +@pytest.fixture +def filter_patches(cf, committers): + make_patch(cf, "adams-open", committers["adams"]) + make_patch( + cf, + "adams-committed", + committers["adams"], + status=PatchOnCommitFest.STATUS_COMMITTED, + ) + make_patch( + cf, + "young-committed", + committers["young"], + status=PatchOnCommitFest.STATUS_COMMITTED, + ) + make_patch(cf, "none-open", None) + + +def test_filter_by_committer(client, cf, committers, filter_patches): + adams = committers["adams"].user_id + assert get_patch_names(client, cf, committer=adams) == [ + "adams-open", + "adams-committed", + ] + + +def test_filter_by_committer_and_committed_status( + client, cf, committers, filter_patches +): + adams = committers["adams"].user_id + assert get_patch_names( + client, cf, committer=adams, status=PatchOnCommitFest.STATUS_COMMITTED + ) == ["adams-committed"] + + +def test_filter_by_committer_combined_with_sort(client, cf, committers): + adams = committers["adams"] + other = make_committer("bsmith", "Bea", "Smith") + make_patch(cf, "adams-1", adams, status=PatchOnCommitFest.STATUS_COMMITTED) + make_patch(cf, "other", other, status=PatchOnCommitFest.STATUS_COMMITTED) + make_patch(cf, "adams-2", adams, status=PatchOnCommitFest.STATUS_COMMITTED) + + response = client.get( + f"/{cf.id}/", + { + "committer": adams.user_id, + "status": PatchOnCommitFest.STATUS_COMMITTED, + "sortkey": -9, + }, + ) + assert response.status_code == 200 + assert response.context["sortkey"] == -9 + assert [p["name"] for p in response.context["patches"]] == [ + "adams-1", + "adams-2", + ] + + +def test_filter_all_committers(client, cf, filter_patches): + # "* All" with no other filters is the same as the plain page + response = client.get(f"/{cf.id}/", {"committer": "-1"}) + assert response.status_code == 302 + assert response["Location"] == f"/{cf.id}/" + + assert get_patch_names(client, cf, committer="-1", sortkey=9) == [ + "adams-open", + "none-open", + "adams-committed", + "young-committed", + ] + + +def test_filter_combines_with_author_and_tag(client, cf, committers, alice): + tag = Tag.objects.create(name="Committer test", color="#000000") + adams = committers["adams"] + match = make_patch(cf, "match", adams) + match.authors.add(alice) + match.tags.add(tag) + no_tag = make_patch(cf, "no-tag", adams) + no_tag.authors.add(alice) + wrong_committer = make_patch(cf, "wrong-committer", committers["young"]) + wrong_committer.authors.add(alice) + wrong_committer.tags.add(tag) + + assert get_patch_names( + client, cf, committer=adams.user_id, author=alice.id, tag=tag.id + ) == ["match"] + + +@pytest.fixture +def other_cf_committer(commitfests): + committer = make_committer("bsmith", "Bea", "Smith") + make_patch(commitfests["in_progress"], "elsewhere", committer) + return committer + + +@pytest.mark.parametrize( + "value", + ["abc", "", "1.5", "other-cf", "no-patches", "999999", str(2**40), str(10**30)], +) +def test_invalid_committer_is_ignored( + client, cf, committers, filter_patches, other_cf_committer, value +): + if value == "other-cf": + value = other_cf_committer.user_id + elif value == "no-patches": + value = make_committer("nnone", "Ned", "None").user_id + + # Without other filters, the parameter is ignored and stripped + response = client.get(f"/{cf.id}/", {"committer": value}) + assert response.status_code == 302 + assert response["Location"] == f"/{cf.id}/" + + assert get_patch_names(client, cf, committer=value, sortkey=4) == [ + "adams-open", + "none-open", + "adams-committed", + "young-committed", + ] + + +def test_committer_choices(client, commitfests, cf, committers, filter_patches): + inactive = make_committer("iold", "Ian", "Old", active=False) + make_patch(cf, "inactive", inactive, status=PatchOnCommitFest.STATUS_COMMITTED) + # Committers without patches in this commitfest are not offered + elsewhere = make_committer("eelse", "Eve", "Elsewhere") + make_patch(commitfests["in_progress"], "elsewhere", elsewhere) + make_committer("nnone", "Ned", "None") + # Users that are not committers are not offered either + author = User.objects.create_user(username="author", last_name="Author") + make_patch(cf, "authored", None).authors.add(author) + + response = client.get(f"/{cf.id}/") + form = response.context["form"] + # Sorted by first name, like the names are shown + assert form.fields["committer"].choices == [ + (-1, "* All"), + (committers["young"].user_id, "Amy Young (ayoung)"), + (inactive.user_id, "Ian Old (iold)"), + (committers["adams"].user_id, "Zed Adams (zadams)"), + ] + # A plain dropdown, not a searchable one + assert b'