From c407919db351c9d4c33b2a562a76e947b9bab40b Mon Sep 17 00:00:00 2001 From: Tommy Ip Date: Fri, 6 Jan 2017 17:56:36 +0000 Subject: [PATCH] Add /authors page. Contributor visualization showing the avatar, user name and number of commits for each contributors. The JSON data would be updated upon deployment, triggered by the `update-prod-static` script. --- .gitignore | 1 + static/styles/portico.css | 55 ++++++++++++++++-- templates/zerver/authors.html | 43 ++++++++++++++ tools/provision.py | 1 + tools/update-authors-json | 106 ++++++++++++++++++++++++++++++++++ tools/update-prod-static | 3 + zerver/fixtures/authors.json | 39 +++++++++++++ zerver/lib/utils.py | 12 +++- zerver/tests/tests.py | 31 ++++++++++ zerver/views/users.py | 20 ++++++- zproject/settings.py | 2 + zproject/urls.py | 4 +- 12 files changed, 306 insertions(+), 11 deletions(-) create mode 100644 templates/zerver/authors.html create mode 100755 tools/update-authors-json create mode 100644 zerver/fixtures/authors.json diff --git a/.gitignore b/.gitignore index b165149853..cc61bcc399 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ coverage/ /zproject/dev-secrets.conf static/js/bundle.js static/generated/emoji +static/generated/github-contributors.json static/locale/language_options.json /node_modules npm-debug.log diff --git a/static/styles/portico.css b/static/styles/portico.css index 4dffa4ff3d..71e71fcc12 100644 --- a/static/styles/portico.css +++ b/static/styles/portico.css @@ -503,6 +503,48 @@ a.bottom-signup-button { font-size: 25px; } +.authors_row { + width: 100%; + table-layout: fixed; +} + +.authors_row a { + color: inherit; +} + +.authors_row td { + width: 100%; + height: 100%; + height: 120px; + padding: 10px; + transition: box-shadow 0.3s ease-in-out; +} + +.authors_row td:hover { + color: #00796B; + box-shadow: 0px 0px 30px #B2DFDB; +} + +.authors_row .avatar { + width: 50%; + text-align: center; + vertical-align: top; +} + +.authors_row .info { + width: 50%; + text-align: left; + vertical-align: top; +} + +.avatar_img { + width: auto; + height: auto; + max-width: 80%; + max-height: 80%; + border-radius: 20%; +} + .integration-lozenges { text-align: center; } @@ -568,12 +610,13 @@ a.bottom-signup-button { display: none; } -.login-page-header, +.api-page-header, .apps-page-header, +.authors-page-header, .feature-page-header, .integrations-page-header, -.register-page-header, -.api-page-header { +.login-page-header, +.register-page-header { font-weight: 300; font-size: 40px; display: block; @@ -594,11 +637,11 @@ a.bottom-signup-button { margin-bottom: 50px; } -.feature-page-header, .api-page-header, .apps-page-header, -.integrations-page-header -{ +.authors-page-header, +.feature-page-header, +.integrations-page-header { padding-top: 40px; } diff --git a/templates/zerver/authors.html b/templates/zerver/authors.html new file mode 100644 index 0000000000..1231bfd396 --- /dev/null +++ b/templates/zerver/authors.html @@ -0,0 +1,43 @@ +{% extends "zerver/portico.html" %} + +{% block portico_content %} +
+ Contributors +
+
+ {% for row in data %} +
+ {% for group in (row[0:2], row[2:4]) %} +
+ {# + Using tables here is a hack to work around the inflexible bootstrap v2.1.1 + grid system. + #} + + + {% for person in group %} + + {% endfor %} + +
+ {% if person %} + +
+ Avatar +
+
+ @{{ person.name }}
+ {{ person.commits }} commits +
+
+ {% endif %} +
+
+ {% endfor %} +
+ {% endfor %} +
+ Statistic last Updated: {{ date }} +
+
+{% endblock %} diff --git a/tools/provision.py b/tools/provision.py index 9544f93d96..f36a87d7a9 100755 --- a/tools/provision.py +++ b/tools/provision.py @@ -219,6 +219,7 @@ def main(options): run(["sudo", "chown", "%s:%s" % (user_id, user_id), EMOJI_CACHE_PATH]) run(["tools/setup/emoji/build_emoji"]) run(["scripts/setup/generate_secrets.py", "--development"]) + run(["tools/update-authors-json", "--use-fixture"]) if options.is_travis and not options.is_production_travis: run(["sudo", "service", "rabbitmq-server", "restart"]) run(["sudo", "service", "redis-server", "restart"]) diff --git a/tools/update-authors-json b/tools/update-authors-json new file mode 100755 index 0000000000..d9109453f9 --- /dev/null +++ b/tools/update-authors-json @@ -0,0 +1,106 @@ +#!/usr/bin/env python +""" +Fetch contributors data from Github using their API, convert it to structured +JSON data for the /authors page. +""" + +from __future__ import absolute_import, print_function +from typing import Any, Dict, List, Optional, Union, Text + +import os +import sys +import argparse +from datetime import date +import subprocess + +from six.moves import range +import requests +import simplejson as json + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +os.environ['DJANGO_SETTINGS_MODULE'] = 'zproject.settings' +from django.conf import settings +from zerver.lib.utils import split_by + +FIXTURE_FILE = os.path.join(os.path.dirname(__file__), '../zerver/fixtures/authors.json') +GITHUB_LINK = 'https://api.github.com/repos/zulip/zulip/stats/contributors' + +parser = argparse.ArgumentParser() +parser.add_argument('--max-retries', type=int, default=3, + help='Number of times to retry fetching data from Github') +# In Travis CI and development environment, we use test fixture to avoid +# fetching from Github constantly. +parser.add_argument('--use-fixture', action='store_true', default=False, + help='Use fixture data instead of fetching from Github') +args = parser.parse_args() + +def fetch_data(retries, link): + # type: (int, str) -> Optional[List[Dict[str, Any]]] + for _ in range(retries): + try: + r = requests.get(link) # type: requests.Response + if r.status_code == 200: + return r.json() + else: + print('Github API return non 200 status.') + except requests.exceptions.RequestException as e: + print(e) + + return None + +def write_to_disk(json_data, out_file): + # type: (Dict[str, Any], str) -> None + with open(out_file, 'w') as f: + try: + f.write("{}\n".format(json.dumps(json_data))) + except IOError as e: + print(e) + sys.exit(1) + +def run_production(): + # type: () -> None + """ + Fetch data from Github and stored it in + `static/generated/github-contributors.json` + """ + json_data = fetch_data(args.max_retries, GITHUB_LINK) # type: Optional[List[Dict[str, Any]]] + if json_data: + # Successfully fetch data from Github + contribs = [] + for user in json_data: + author = user.get('author') + result_user = dict( + avatar=author.get('avatar_url'), + name=author.get('login'), + commits=user.get('total') + ) + contribs.append(result_user) + + out_contrib_data = split_by( + sorted(contribs, key=lambda k: k.get('commits'), reverse=True), + 4, None + ) # type: List[List[Optional[Dict[str, Union[Text, int]]]]] + + out_data = dict( + data=out_contrib_data, + date=str(date.today()) + ) # type: Dict[str, Any] + + write_to_disk(out_data, settings.CONTRIBUTORS_DATA) + + else: + print('Fail to fetch data from Github.') + sys.exit(1) + +def copy_fixture(): + # type: () -> None + """ + Copy test fixture file from zerver/fixtures. This is used to avoid + constantly fetching data from Github during testing. + """ + subprocess.check_call(['cp', FIXTURE_FILE, settings.CONTRIBUTORS_DATA]) + +if args.use_fixture: + copy_fixture() +else: + run_production() diff --git a/tools/update-prod-static b/tools/update-prod-static index e4afd59ac1..0d6800fc6f 100755 --- a/tools/update-prod-static +++ b/tools/update-prod-static @@ -64,4 +64,7 @@ subprocess.check_call(['cp', '-a', 'static/locale', os.path.join(settings.STATIC_ROOT, 'locale')], stdout=fp, stderr=fp) +# Generate /authors page markdown +subprocess.check_call(['./tools/update-authors-json'], stdout=fp) + fp.close() diff --git a/zerver/fixtures/authors.json b/zerver/fixtures/authors.json new file mode 100644 index 0000000000..fdfcaf2776 --- /dev/null +++ b/zerver/fixtures/authors.json @@ -0,0 +1,39 @@ +{ + "date": "2050-11-5", + "data": [ + [{ + "commits": 4131, + "name": "timabbott", + "avatar": "https://avatars.githubusercontent.com/u/2746074?v=3" + }, { + "commits": 1102, + "name": "zbenjamin", + "avatar": "https://avatars.githubusercontent.com/u/58684?v=3" + }, { + "commits": 704, + "name": "wdaher", + "avatar": "https://avatars.githubusercontent.com/u/109246?v=3" + }, { + "commits": 671, + "name": "lfranchi", + "avatar": "https://avatars.githubusercontent.com/u/67976?v=3" + }], + [{ + "commits": 524, + "name": "showell", + "avatar": "https://avatars.githubusercontent.com/u/142908?v=3" + }, { + "commits": 425, + "name": "lfaraone", + "avatar": "https://avatars.githubusercontent.com/u/73410?v=3" + }, { + "commits": 385, + "name": "sharmaeklavya2", + "avatar": "https://avatars.githubusercontent.com/u/3839472?v=3" + }, { + "commits": 295, + "name": "umairwaheed", + "avatar": "https://avatars.githubusercontent.com/u/641763?v=3" + }] + ] +} diff --git a/zerver/lib/utils.py b/zerver/lib/utils.py index d869e41bcc..ae04e9d8f1 100644 --- a/zerver/lib/utils.py +++ b/zerver/lib/utils.py @@ -10,11 +10,12 @@ import hashlib import heapq import itertools import os +import sys from time import sleep from django.conf import settings from django.http import HttpRequest -from six.moves import range +from six.moves import range, map, zip_longest from zerver.lib.str_utils import force_text T = TypeVar('T') @@ -204,3 +205,12 @@ def check_subdomain(realm_subdomain, user_subdomain): if realm_subdomain != user_subdomain: return False return True + +def split_by(array, group_size, filler): + # type: (List[Any], int, Any) -> List[List[Any]] + """ + Group elements into list of size `group_size` and fill empty cells with + `filler`. Recipe from https://docs.python.org/3/library/itertools.html + """ + args = [iter(array)] * group_size + return list(map(list, zip_longest(*args, fillvalue=filler))) diff --git a/zerver/tests/tests.py b/zerver/tests/tests.py index 5d217cc86b..c9f6eb4a04 100644 --- a/zerver/tests/tests.py +++ b/zerver/tests/tests.py @@ -37,6 +37,7 @@ from zerver.lib.notifications import handle_missedmessage_emails from zerver.lib.session_user import get_session_dict_user from zerver.middleware import is_slow_query from zerver.lib.avatar import avatar_url +from zerver.lib.utils import split_by from zerver.worker import queue_processors @@ -50,6 +51,7 @@ import time import ujson import random import filecmp +import subprocess def bail(msg): # type: (str) -> None @@ -2129,6 +2131,28 @@ class HomeTest(ZulipTestCase): result = self.client_get("/api/v1/generate_204") self.assertEqual(result.status_code, 204) +class AuthorsPageTest(ZulipTestCase): + def setUp(self): + # type: () -> None + """ Manual installation which did not execute `tools/provision.py` + would not have the `static/generated/github-contributors.json` fixture + file. + """ + if not os.path.exists(settings.CONTRIBUTORS_DATA): + # Copy the fixture file in `zerver/fixtures` to `static/generated` + update_script = os.path.join(os.path.dirname(__file__), + '../../tools/update-authors-json') + subprocess.check_call([update_script, '--use-fixture']) + + def test_endpoint(self): + # type: () -> None + result = self.client_get('/authors/') + self.assert_in_success_response( + ['Contributors', 'Statistic last Updated:', 'commits', + '@timabbott'], + result + ) + class MutedTopicsTests(ZulipTestCase): def test_json_set(self): # type: () -> None @@ -2337,3 +2361,10 @@ class TestFindMyTeam(ZulipTestCase): result = self.client_post('/find_my_team/', data) self.assertEqual(result.status_code, 200) self.assertIn("Please enter at most 10", result.content.decode('utf8')) + +class UtilsUnitTest(TestCase): + def test_split_by(self): + # type: () -> None + flat_list = [1, 2, 3, 4, 5, 6, 7] + expected_result = [[1, 2], [3, 4], [5, 6], [7, None]] + self.assertEqual(split_by(flat_list, 2, None), expected_result) diff --git a/zerver/views/users.py b/zerver/views/users.py index b9773b95a8..d935cda608 100644 --- a/zerver/views/users.py +++ b/zerver/views/users.py @@ -1,9 +1,14 @@ from __future__ import absolute_import +from typing import Text, Union, Optional, Dict, Any, List, Tuple + +import os +import simplejson as json from django.http import HttpRequest, HttpResponse from django.utils.translation import ugettext as _ from django.shortcuts import redirect +from django.conf import settings from six.moves import map from zerver.decorator import has_request_variables, REQ, JsonableError, \ @@ -20,9 +25,8 @@ from zerver.lib.validator import check_bool, check_string from zerver.lib.utils import generate_random_token from zerver.models import UserProfile, Stream, Realm, Message, get_user_profile_by_email, \ get_stream, email_allowed_for_realm +from zproject.jinja2 import render_to_response -from typing import Text -from typing import Optional, Dict, Any def deactivate_user_backend(request, user_profile, email): # type: (HttpRequest, UserProfile, Text) -> HttpResponse @@ -361,3 +365,15 @@ def get_profile_backend(request, user_profile): result['max_message_id'] = messages[0].id return json_success(result) + +def authors_view(request): + # type: (HttpRequest) -> HttpResponse + + with open(settings.CONTRIBUTORS_DATA) as f: + data = json.load(f) + + return render_to_response( + 'zerver/authors.html', + data, + request=request + ) diff --git a/zproject/settings.py b/zproject/settings.py index 22878f2d77..703e13c8f6 100644 --- a/zproject/settings.py +++ b/zproject/settings.py @@ -1103,3 +1103,5 @@ if PRODUCTION: PROFILE_ALL_REQUESTS = False CROSS_REALM_BOT_EMAILS = set(('feedback@zulip.com', 'notification-bot@zulip.com')) + +CONTRIBUTORS_DATA = os.path.join(STATIC_ROOT, 'generated/github-contributors.json') diff --git a/zproject/urls.py b/zproject/urls.py index 33d3c5b925..4c7aa01d6c 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -132,11 +132,11 @@ i18n_urls = [ url(r'^robots\.txt$', RedirectView.as_view(url='/static/robots.txt', permanent=True)), # Landing page, features pages, signup form, etc. - url(r'^hello/$', TemplateView.as_view(template_name='zerver/hello.html'), - name='landing-page'), + url(r'^hello/$', TemplateView.as_view(template_name='zerver/hello.html'), name='landing-page'), url(r'^new-user/$', RedirectView.as_view(url='/hello', permanent=True)), url(r'^features/$', TemplateView.as_view(template_name='zerver/features.html')), url(r'^find_my_team/$', zerver.views.registration.find_my_team, name='zerver.views.registration.find_my_team'), + url(r'^authors/$', zerver.views.users.authors_view, name='zerver.views.users.authors_view') ] # If a Terms of Service is supplied, add that route