Repository: translate/pootle

Issue:
Add --until to contributors command



Discussion:

Resolving patch:
diff --git a/pootle/apps/pootle_app/management/commands/contributors.py b/pootle/apps/pootle_app/management/commands/contributors.py
--- a/pootle/apps/pootle_app/management/commands/contributors.py
+++ b/pootle/apps/pootle_app/management/commands/contributors.py
@@ -7,18 +7,30 @@
 # AUTHORS file for copyright and authorship information.
 
 import os
-from collections import Counter
+from argparse import ArgumentTypeError
+from dateutil.parser import parse as parse_datetime
+from email.utils import formataddr
 
 os.environ["DJANGO_SETTINGS_MODULE"] = "pootle.settings"
 
-from django.contrib.auth import get_user_model
-
-from pootle_store.models import Unit
+from pootle.core.delegate import contributors
+from pootle.core.utils.timezone import make_aware
 
 from . import PootleCommand
 
 
-User = get_user_model()
+def get_aware_datetime(dt_string):
+    """Return an aware datetime parsed from a datetime or date string.
+
+    Datetime or date string can be any format parsable by dateutil.parser.parse
+    """
+    if not dt_string:
+        return None
+    try:
+        return make_aware(parse_datetime(dt_string))
+    except ValueError:
+        raise ArgumentTypeError('The provided datetime/date string is not '
+                                'valid: "%s"' % dt_string)
 
 
 class Command(PootleCommand):
@@ -29,110 +41,67 @@ def add_arguments(self, parser):
         super(Command, self).add_arguments(parser)
         parser.add_argument(
             "--since",
+            type=get_aware_datetime,
             action="store",
             dest="since",
             help=('ISO 8601 format: 2016-01-24T23:15:22+0000 or '
-                  '"2016-01-24 23:15:22 +0000"'),
+                  '"2016-01-24 23:15:22 +0000" or "2016-01-24"'),
+        )
+        parser.add_argument(
+            "--until",
+            type=get_aware_datetime,
+            action="store",
+            dest="until",
+            help=('ISO 8601 format: 2016-01-24T23:15:22+0000 or '
+                  '"2016-01-24 23:15:22 +0000" or "2016-01-24"'),
         )
         parser.add_argument(
             "--sort-by",
-            default="name",
-            choices=["name", "contributions"],
+            default="username",
+            choices=["username", "contributions"],
             dest="sort_by",
             help="Sort by specified item. Accepts: %(choices)s. "
                  "Default: %(default)s",
         )
-        parser.add_argument(
-            "--only-emails",
+        anon_or_mailmerge = parser.add_mutually_exclusive_group(required=False)
+        anon_or_mailmerge.add_argument(
+            "--include-anonymous",
             action="store_true",
-            dest="only_emails",
+            dest="include_anon",
+            help="Include anonymous contributions.",
+        )
+        anon_or_mailmerge.add_argument(
+            "--mailmerge",
+            action="store_true",
+            dest="mailmerge",
             help="Output only names and email addresses. Contribution counts "
                  "are excluded.",
         )
 
-    def _get_revision_from_since(self, since):
-        from pootle.core.dateparse import parse_datetime
-        from pootle.core.utils.timezone import make_aware
-        from pootle_statistics.models import Submission
-
-        if " " in since:
-            since = "%sT%s%s" % tuple(since.split(" "))
-
-        since = make_aware(parse_datetime(since))
-
-        submissions_qs = Submission.objects.filter(creation_time__lt=since)
-
-        if self.projects:
-            submissions_qs = submissions_qs.filter(
-                translation_project__project__code__in=self.projects,
-            )
-
-        if self.languages:
-            submissions_qs = submissions_qs.filter(
-                translation_project__language__code__in=self.languages,
-            )
-
-        submission = submissions_qs.last()
-
-        if submission is None:
-            return 0
-
-        return submission.unit.revision
-
-    def handle_all(self, **options):
-        system_user = User.objects.get_system_user()
-        units = Unit.objects.exclude(submitted_by=system_user) \
-                            .exclude(submitted_by=None)
-
-        if options["only_emails"]:
-            nobody_user = User.objects.get_nobody_user()
-            units = units.exclude(submitted_by=nobody_user)
-
-        if options["since"]:
-            units = units.filter(
-                revision__gte=self._get_revision_from_since(options["since"]),
-            )
-
-        if self.projects:
-            units = units.filter(
-                store__translation_project__project__code__in=self.projects,
-            )
-
-        if self.languages:
-            units = units.filter(
-                store__translation_project__language__code__in=self.languages,
-            )
-
-        contribs = Counter()
-        for v in units.values("submitted_by"):
-            contribs.update((v["submitted_by"], ))
-
-        self.list_contributions(contribs, options["sort_by"],
-                                options["only_emails"])
-
-    def list_contributions(self, contribs, sort_by, only_emails):
-        if sort_by == "name":
-            contributions = contribs.items()
-        else:
-            contributions = contribs.most_common()
-
-        out = []
-        for id, count in contributions:
-            user = User.objects.get(id=id)
-            name = user.display_name
-            if only_emails:
-                name = name.replace(",", "")
-            if user.email:
-                name += " <%s>" % (user.email)
-            elif only_emails:
-                continue
-            if not only_emails:
-                name = "%s (%i contributions)" % (name, count)
-            out.append(name)
-
-        if sort_by == "name":
-            # Sort users alphabetically
-            out = sorted(out)
-
-        for line in out:
-            self.stdout.write(line)
+    @property
+    def contributors(self):
+        return contributors.get()
+
+    def contrib_kwargs(self, **options):
+        kwargs = {
+            k: v
+            for k, v
+            in options.items()
+            if k in ["projects", "languages", "include_anon",
+                     "since", "until", "sort_by"]}
+        kwargs["project_codes"] = kwargs.pop("projects", None)
+        kwargs["language_codes"] = kwargs.pop("languages", None)
+        return kwargs
+
+    def handle(self, **options):
+        contributors = self.contributors(**self.contrib_kwargs(**options))
+        for username, user in contributors.items():
+            name = user["full_name"].strip() or username
+
+            if options["mailmerge"]:
+                email = user["email"].strip()
+                if email:
+                    self.stdout.write(formataddr((name, email)))
+            else:
+                self.stdout.write("%s (%s contributions)" %
+                                  (name, user["contributions"]))
diff --git a/pytest_pootle/fixtures/contributors.py b/pytest_pootle/fixtures/contributors.py
new file mode 100644
--- /dev/null
+++ b/pytest_pootle/fixtures/contributors.py
@@ -0,0 +1,131 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) Pootle contributors.
+#
+# This file is a part of the Pootle project. It is distributed under the GPL3
+# or later license. See the LICENSE file for a copy of the license and the
+# AUTHORS file for copyright and authorship information.
+
+from collections import OrderedDict
+
+import pytest
+
+
+CONTRIBUTORS_KWARGS = dict(
+    noargs={},
+    projects=dict(project_codes=["project0"]),
+    languages=dict(language_codes=["language0"]),
+    projects_and_languages=dict(
+        project_codes=["project0"],
+        language_codes=["language0"]),
+    since=dict(since="2000-11-10"),
+    until=dict(until="2000-11-10"),
+    since_and_until=dict(since="2000-11-10", until="2000-11-10"),
+    sort_by=dict(sort_by="contributions"))
+
+
+CONTRIBUTORS_WITH_EMAIL = OrderedDict((
+    ('admin', {
+        'username': 'admin',
+        'full_name': '',
+        'email': '',
+    }),
+    ('member', {
+        'username': 'member',
+        'full_name': '',
+        'email': 'member@membership.us',
+    }),
+    ('funkymember', {
+        'username': 'funkymember',
+        'full_name': 'Funky " member with <> and @ and stuff',
+        'email': 'funky_member@membership.dk',
+    }),
+    ('fullmember', {
+        'username': 'fullmember',
+        'full_name': 'Just a member',
+        'email': 'full_member@membership.fr',
+    }),
+    ('comma_member', {
+        'username': 'comma_member',
+        'full_name': 'Member, with comma',
+        'email': 'comma_member@membership.de',
+    }),
+))
+
+
+@pytest.fixture
+def default_contributors_kwargs():
+    return OrderedDict(
+        (("include_anon", False),
+         ("since", None),
+         ("until", None),
+         ("project_codes", None),
+         ("language_codes", None),
+         ("sort_by", "username"),
+         ("mailmerge", False)))
+
+
+@pytest.fixture(params=CONTRIBUTORS_KWARGS)
+def contributors_kwargs(request):
+    return CONTRIBUTORS_KWARGS[request.param]
+
+
+@pytest.fixture
+def dummy_contributors(request, default_contributors_kwargs):
+    from pootle.core.delegate import contributors
+    from pootle.core.plugin import getter
+
+    from pootle_statistics.utils import Contributors
+
+    orig_receivers = contributors.receivers
+    contributors.receivers = []
+
+    class DummyContributors(Contributors):
+
+        @property
+        def contributors(self):
+            # Hack the output to get back our kwargs.
+            _result_kwargs = OrderedDict()
+            for k in default_contributors_kwargs.keys():
+                _result_kwargs[k] = dict(
+                    full_name=k,
+                    contributions=getattr(
+                        self, k, default_contributors_kwargs[k]))
+            return _result_kwargs
+
+    @getter(contributors, weak=False)
+    def get_dummy_contribs(**kwargs):
+        return DummyContributors
+
+    def _reset_contributors():
+        contributors.receivers = orig_receivers
+
+    request.addfinalizer(_reset_contributors)
+
+
+@pytest.fixture
+def dummy_email_contributors(request):
+    from pootle.core.delegate import contributors
+    from pootle.core.plugin import getter
+
+    from pootle_statistics.utils import Contributors
+
+    orig_receivers = contributors.receivers
+    contributors.receivers = []
+
+    class DummyContributors(Contributors):
+
+        @property
+        def contributors(self):
+            return OrderedDict(
+                sorted(CONTRIBUTORS_WITH_EMAIL.items(),
+                       key=lambda x: str.lower(x[1]['username'])))
+
+    @getter(contributors, weak=False)
+    def get_dummy_contribs(**kwargs):
+        return DummyContributors
+
+    def _reset_contributors():
+        contributors.receivers = orig_receivers
+
+    request.addfinalizer(_reset_contributors)


Test patch:
diff --git a/tests/commands/contributors.py b/tests/commands/contributors.py
--- a/tests/commands/contributors.py
+++ b/tests/commands/contributors.py
@@ -6,56 +6,105 @@
 # or later license. See the LICENSE file for a copy of the license and the
 # AUTHORS file for copyright and authorship information.
 
+from argparse import ArgumentTypeError
+from collections import OrderedDict
+from dateutil.parser import parse as parse_datetime
+from email.utils import formataddr
+
 import pytest
 
 from django.core.management import call_command
+from django.core.management.base import CommandError
+
+from pootle.core.utils.timezone import make_aware
+from pootle_app.management.commands.contributors import get_aware_datetime
+from pytest_pootle.fixtures.contributors import CONTRIBUTORS_WITH_EMAIL
+
+
+def test_contributors_get_aware_datetime():
+    """Get an aware datetime from a valid string."""
+    iso_datetime = make_aware(parse_datetime("2016-01-24T23:15:22+0000"))
+
+    # Test ISO 8601 datetime.
+    assert iso_datetime == get_aware_datetime("2016-01-24T23:15:22+0000")
+
+    # Test git-like datetime.
+    assert iso_datetime == get_aware_datetime("2016-01-24 23:15:22 +0000")
+
+    # Test just an ISO 8601 date.
+    iso_datetime = make_aware(parse_datetime("2016-01-24T00:00:00+0000"))
+    assert iso_datetime == get_aware_datetime("2016-01-24")
+
+    # Test None.
+    assert get_aware_datetime(None) is None
 
-from pootle_store.models import Unit
+    # Test empty string.
+    assert get_aware_datetime("") is None
+
+    # Test non-empty string.
+    with pytest.raises(ArgumentTypeError):
+        get_aware_datetime("THIS FAILS")
+
+    # Test blank string.
+    with pytest.raises(ArgumentTypeError):
+        get_aware_datetime(" ")
 
 
 @pytest.mark.cmd
 @pytest.mark.django_db
-def test_contributors_noargs(capfd, en_tutorial_po_member_updated):
+def test_cmd_contributors(capfd, dummy_contributors,
+                          default_contributors_kwargs, contributors_kwargs):
     """Contributors across the site."""
-    call_command('contributors')
-    out, err = capfd.readouterr()
-    contribs = Unit.objects.filter(submitted_by__username="member")
-    assert ("Member (%d contributions)" % contribs.count()) in out
+    result_kwargs = default_contributors_kwargs.copy()
+    result_kwargs.update(contributors_kwargs)
+    cmd_args = []
+    for k in ["project", "language"]:
+        if result_kwargs["%s_codes" % k]:
+            for arg in result_kwargs["%s_codes" % k]:
+                cmd_args.extend(["--%s" % k, arg])
+    for k in ["since", "until"]:
+        if result_kwargs[k]:
+            cmd_args.extend(
+                ["--%s" % k,
+                 result_kwargs[k]])
+            result_kwargs[k] = make_aware(parse_datetime(result_kwargs[k]))
 
+    if result_kwargs["sort_by"]:
+        cmd_args.extend(["--sort-by", result_kwargs["sort_by"]])
 
-@pytest.mark.cmd
-@pytest.mark.django_db
-def test_contributors_project(capfd, en_tutorial_po_member_updated):
-    """Contributors in a given project."""
-    call_command('contributors', '--project=tutorial')
+    call_command('contributors', *cmd_args)
     out, err = capfd.readouterr()
-    contribs = Unit.objects.filter(
-        submitted_by__username="member",
-        store__translation_project__project__code="tutorial")
-    assert ("Member (%d contributions)" % contribs.count()) in out
+    assert out.strip() == str(
+        "\n".join(
+            "%s (%s contributions)" % (k, v)
+            for k, v
+            in result_kwargs.items()))
 
 
 @pytest.mark.cmd
-@pytest.mark.django_db
-def test_contributors_language(capfd, en_tutorial_po_member_updated):
-    """Contributors in a given language."""
-    call_command('contributors', '--language=en')
-    out, err = capfd.readouterr()
-    contribs = Unit.objects.filter(
-        submitted_by__username="member",
-        store__translation_project__language__code="en")
-    assert ("Member (%d contributions)" % contribs.count()) in out
+def test_cmd_contributors_mutually_exclusive(capfd):
+    """Test mutually exclusive arguments are not accepted."""
+    with pytest.raises(CommandError) as e:
+        call_command('contributors', '--include-anonymous', '--mailmerge')
+    assert ("argument --mailmerge: not allowed with argument "
+            "--include-anonymous") in str(e)
+
+    with pytest.raises(CommandError) as e:
+        call_command('contributors', '--mailmerge', '--include-anonymous')
+    assert ("argument --include-anonymous: not allowed with argument "
+            "--mailmerge") in str(e)
 
 
 @pytest.mark.cmd
 @pytest.mark.django_db
-def test_contributors_sort(capfd, en_tutorial_po_member_updated, member):
-    """Contributors in contribution order."""
-    member.email = "member@test.email"
-    member.save()
-    contribs = Unit.objects.filter(submitted_by__username="member")
-    call_command('contributors', '--sort-by=contributions')
+def test_cmd_contributors_mailmerge(capfd, dummy_email_contributors):
+    """Contributors across the site with mailmerge."""
+    call_command('contributors', '--mailmerge')
     out, err = capfd.readouterr()
-    assert (
-        ("Member <member@test.email> (%d contributions)" % contribs.count())
-        in out)
+    temp = OrderedDict(sorted(CONTRIBUTORS_WITH_EMAIL.items(),
+                              key=lambda x: str.lower(x[1]['username'])))
+    expected = [
+        formataddr((item["full_name"].strip() or item["username"],
+                    item['email'].strip()))
+        for item in temp.values() if item['email'].strip()]
+    assert out.strip() == "\n".join(expected)