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.
This commit is contained in:
Tommy Ip 2017-01-06 17:56:36 +00:00 committed by Tim Abbott
parent 747f66bfe1
commit c407919db3
12 changed files with 306 additions and 11 deletions

1
.gitignore vendored
View File

@ -18,6 +18,7 @@ coverage/
/zproject/dev-secrets.conf /zproject/dev-secrets.conf
static/js/bundle.js static/js/bundle.js
static/generated/emoji static/generated/emoji
static/generated/github-contributors.json
static/locale/language_options.json static/locale/language_options.json
/node_modules /node_modules
npm-debug.log npm-debug.log

View File

@ -503,6 +503,48 @@ a.bottom-signup-button {
font-size: 25px; 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 { .integration-lozenges {
text-align: center; text-align: center;
} }
@ -568,12 +610,13 @@ a.bottom-signup-button {
display: none; display: none;
} }
.login-page-header, .api-page-header,
.apps-page-header, .apps-page-header,
.authors-page-header,
.feature-page-header, .feature-page-header,
.integrations-page-header, .integrations-page-header,
.register-page-header, .login-page-header,
.api-page-header { .register-page-header {
font-weight: 300; font-weight: 300;
font-size: 40px; font-size: 40px;
display: block; display: block;
@ -594,11 +637,11 @@ a.bottom-signup-button {
margin-bottom: 50px; margin-bottom: 50px;
} }
.feature-page-header,
.api-page-header, .api-page-header,
.apps-page-header, .apps-page-header,
.integrations-page-header .authors-page-header,
{ .feature-page-header,
.integrations-page-header {
padding-top: 40px; padding-top: 40px;
} }

View File

@ -0,0 +1,43 @@
{% extends "zerver/portico.html" %}
{% block portico_content %}
<div class="authors-page-header">
<i class='icon-vector-github'></i> Contributors
</div>
<div class="container">
{% for row in data %}
<div class="row">
{% for group in (row[0:2], row[2:4]) %}
<div class="span6">
{#
Using tables here is a hack to work around the inflexible bootstrap v2.1.1
grid system.
#}
<table class="authors_row">
<tr>
{% for person in group %}
<td>
{% if person %}
<a href="https://github.com/{{ person.name }}">
<div class="avatar float-left">
<img class="avatar_img" src="{{ person.avatar }}" alt="Avatar" />
</div>
<div class='info float-right'>
<b>@{{ person.name }}</b><br />
{{ person.commits }} commits
</div>
</a>
{% endif %}
</td>
{% endfor %}
</tr>
</table>
</div>
{% endfor %}
</div>
{% endfor %}
<div class="span12">
Statistic last Updated: {{ date }}
</div>
</div>
{% endblock %}

View File

@ -219,6 +219,7 @@ def main(options):
run(["sudo", "chown", "%s:%s" % (user_id, user_id), EMOJI_CACHE_PATH]) run(["sudo", "chown", "%s:%s" % (user_id, user_id), EMOJI_CACHE_PATH])
run(["tools/setup/emoji/build_emoji"]) run(["tools/setup/emoji/build_emoji"])
run(["scripts/setup/generate_secrets.py", "--development"]) run(["scripts/setup/generate_secrets.py", "--development"])
run(["tools/update-authors-json", "--use-fixture"])
if options.is_travis and not options.is_production_travis: if options.is_travis and not options.is_production_travis:
run(["sudo", "service", "rabbitmq-server", "restart"]) run(["sudo", "service", "rabbitmq-server", "restart"])
run(["sudo", "service", "redis-server", "restart"]) run(["sudo", "service", "redis-server", "restart"])

106
tools/update-authors-json Executable file
View File

@ -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()

View File

@ -64,4 +64,7 @@ subprocess.check_call(['cp', '-a', 'static/locale',
os.path.join(settings.STATIC_ROOT, 'locale')], os.path.join(settings.STATIC_ROOT, 'locale')],
stdout=fp, stderr=fp) stdout=fp, stderr=fp)
# Generate /authors page markdown
subprocess.check_call(['./tools/update-authors-json'], stdout=fp)
fp.close() fp.close()

View File

@ -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"
}]
]
}

View File

@ -10,11 +10,12 @@ import hashlib
import heapq import heapq
import itertools import itertools
import os import os
import sys
from time import sleep from time import sleep
from django.conf import settings from django.conf import settings
from django.http import HttpRequest 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 from zerver.lib.str_utils import force_text
T = TypeVar('T') T = TypeVar('T')
@ -204,3 +205,12 @@ def check_subdomain(realm_subdomain, user_subdomain):
if realm_subdomain != user_subdomain: if realm_subdomain != user_subdomain:
return False return False
return True 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)))

View File

@ -37,6 +37,7 @@ from zerver.lib.notifications import handle_missedmessage_emails
from zerver.lib.session_user import get_session_dict_user from zerver.lib.session_user import get_session_dict_user
from zerver.middleware import is_slow_query from zerver.middleware import is_slow_query
from zerver.lib.avatar import avatar_url from zerver.lib.avatar import avatar_url
from zerver.lib.utils import split_by
from zerver.worker import queue_processors from zerver.worker import queue_processors
@ -50,6 +51,7 @@ import time
import ujson import ujson
import random import random
import filecmp import filecmp
import subprocess
def bail(msg): def bail(msg):
# type: (str) -> None # type: (str) -> None
@ -2129,6 +2131,28 @@ class HomeTest(ZulipTestCase):
result = self.client_get("/api/v1/generate_204") result = self.client_get("/api/v1/generate_204")
self.assertEqual(result.status_code, 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): class MutedTopicsTests(ZulipTestCase):
def test_json_set(self): def test_json_set(self):
# type: () -> None # type: () -> None
@ -2337,3 +2361,10 @@ class TestFindMyTeam(ZulipTestCase):
result = self.client_post('/find_my_team/', data) result = self.client_post('/find_my_team/', data)
self.assertEqual(result.status_code, 200) self.assertEqual(result.status_code, 200)
self.assertIn("Please enter at most 10", result.content.decode('utf8')) 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)

View File

@ -1,9 +1,14 @@
from __future__ import absolute_import 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.http import HttpRequest, HttpResponse
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.shortcuts import redirect from django.shortcuts import redirect
from django.conf import settings
from six.moves import map from six.moves import map
from zerver.decorator import has_request_variables, REQ, JsonableError, \ 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.lib.utils import generate_random_token
from zerver.models import UserProfile, Stream, Realm, Message, get_user_profile_by_email, \ from zerver.models import UserProfile, Stream, Realm, Message, get_user_profile_by_email, \
get_stream, email_allowed_for_realm 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): def deactivate_user_backend(request, user_profile, email):
# type: (HttpRequest, UserProfile, Text) -> HttpResponse # type: (HttpRequest, UserProfile, Text) -> HttpResponse
@ -361,3 +365,15 @@ def get_profile_backend(request, user_profile):
result['max_message_id'] = messages[0].id result['max_message_id'] = messages[0].id
return json_success(result) 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
)

View File

@ -1103,3 +1103,5 @@ if PRODUCTION:
PROFILE_ALL_REQUESTS = False PROFILE_ALL_REQUESTS = False
CROSS_REALM_BOT_EMAILS = set(('feedback@zulip.com', 'notification-bot@zulip.com')) CROSS_REALM_BOT_EMAILS = set(('feedback@zulip.com', 'notification-bot@zulip.com'))
CONTRIBUTORS_DATA = os.path.join(STATIC_ROOT, 'generated/github-contributors.json')

View File

@ -132,11 +132,11 @@ i18n_urls = [
url(r'^robots\.txt$', RedirectView.as_view(url='/static/robots.txt', permanent=True)), url(r'^robots\.txt$', RedirectView.as_view(url='/static/robots.txt', permanent=True)),
# Landing page, features pages, signup form, etc. # Landing page, features pages, signup form, etc.
url(r'^hello/$', TemplateView.as_view(template_name='zerver/hello.html'), url(r'^hello/$', TemplateView.as_view(template_name='zerver/hello.html'), name='landing-page'),
name='landing-page'),
url(r'^new-user/$', RedirectView.as_view(url='/hello', permanent=True)), url(r'^new-user/$', RedirectView.as_view(url='/hello', permanent=True)),
url(r'^features/$', TemplateView.as_view(template_name='zerver/features.html')), 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'^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 # If a Terms of Service is supplied, add that route