Skip to content

Commit

Permalink
Add basic contributor page with leaderboards for guide count and comm…
Browse files Browse the repository at this point in the history
…it stats

- This change introduces another optional environment variable,
  IGNORE_STATS_FOR.  This is a CSV string of github user names to ignore when
  displaying stats.  It's defaulted to the REPO_OWNER only.
  • Loading branch information
durden committed Apr 6, 2016
1 parent 69a6695 commit 10bd2c6
Show file tree
Hide file tree
Showing 10 changed files with 306 additions and 3 deletions.
9 changes: 8 additions & 1 deletion example_config.py
Expand Up @@ -15,7 +15,8 @@
'MAILCHIMP_LIST_ID', 'MAILCHIMP_STACKS_GROUP_NAME',
'SECONDARY_REPO_OWNER', 'SECONDARY_REPO_NAME',
'DOMAIN', 'CELERY_BROKER_URL',
'CELERY_TASK_SERIALIZER', 'HOSTING_SUBDIRECTORY')
'CELERY_TASK_SERIALIZER', 'HOSTING_SUBDIRECTORY',
'IGNORE_STATS_FOR')


class Config(object):
Expand Down Expand Up @@ -56,6 +57,12 @@ class Config(object):
# to this app. Thus, this app responds to '/guides' with the '/' rule.
HOSTING_SUBDIRECTORY = ''

# CSV string of user login names to ignore stats for. This is useful if
# you want to ignore the repo owner. You can easily add to this list.
IGNORE_STATS_FOR = ''
if REPO_OWNER is not None:
IGNORE_STATS_FOR = ','.join([REPO_OWNER])


class DevelopmentConfig(Config):
DEBUG = True
31 changes: 31 additions & 0 deletions pskb_website/cache.py
@@ -1,3 +1,5 @@
# FIXME: Make every function user our save/get wrappers

"""
Caching utilities
Expand Down Expand Up @@ -60,6 +62,35 @@ def _wrapper(*args, **kwargs):
return _wrapper


@verify_redis_instance
def save(key, value, timeout=DEFAULT_CACHE_TIMEOUT):
"""
Generic function to save a key/value pair
:param key: Key to save
:param value: Value to save
:param timeout: Timeout in seconds to cache text, use None for no timeout
:returns None:
"""

redis_obj.set(key, value)

if timeout is not None:
redis_obj.expire(key, timeout)


@verify_redis_instance
def get(key):
"""
Look for cached value with given key
:param key: Key data was cached with
:returns: Value saved or None if not found
"""

return redis_obj.get(key)


@verify_redis_instance
def read_file(path, branch):
"""
Expand Down
2 changes: 2 additions & 0 deletions pskb_website/models/__init__.py
Expand Up @@ -13,6 +13,7 @@
from .article import get_public_articles_for_author
from .article import find_article_by_title
from .article import change_article_stack
from .article import author_stats

from .file import read_file
from .file import read_redirects
Expand All @@ -24,3 +25,4 @@

from .image import save_image
from .lib import to_json
from .lib import weekly_contribution_stats
45 changes: 45 additions & 0 deletions pskb_website/models/article.py
Expand Up @@ -220,6 +220,51 @@ def get_public_articles_for_author(author_name):
yield article


def author_stats(statuses=None):
"""
Get number of articles for each author
:param statuses: List of statuses to aggregate stats for
:param statuses: Optional status to aggregate stats for, all possible
statuses are counted if None is given
:returns: Dictionary mapping author names to number of articles::
{author_name: [article_count, avatar_url]}
Note avatar_url can be None and is considered optional
"""

cache_key = 'author-stats'
stats = cache.get(cache_key)
if stats:
return json.loads(stats)

stats = {}
statuses = [get_available_articles(status=st) for st in statuses]
for article in itertools.chain(*statuses):
# This is ALMOST a good fit for collections.defaultdict() but we need
# to inspect the avatar URL each time to see if it can be replaced with
# a non-empty value since this is optional article information.
try:
prev_stats = stats[article.author_name]
except KeyError:
prev_stats = [1, None]
else:
prev_stats[0] += 1

if prev_stats[1] is None and article.image_url is not None:
prev_stats[1] = article.image_url

stats[article.author_name] = prev_stats

if not stats:
return stats

# Just fetch stats every 30 minutes, this is not a critical bit of data
cache.save(cache_key, json.dumps(stats), timeout=30 * 60)
return stats


def read_article(path, rendered_text=True, branch=u'master', repo_path=None,
allow_missing=False):
"""
Expand Down
42 changes: 42 additions & 0 deletions pskb_website/models/lib.py
Expand Up @@ -5,6 +5,9 @@
import copy
import json

from .. import remote
from .. import cache


def to_json(object_, exclude_attrs=None):
"""
Expand All @@ -26,3 +29,42 @@ def to_json(object_, exclude_attrs=None):
# Print it to a string in a pretty format. Whitespace doesn't matter so
# might as well make it more readable.
return json.dumps(dict_, sort_keys=True, indent=4, separators=(',', ': '))


def weekly_contribution_stats():
"""
Get total and weekly contribution stats for default repository
:returns: List of dictionaries for every contributor to repository ordered
by most commits this week
"""

cache_key = 'commit-stats'
stats = cache.get(cache_key)
if stats:
return json.loads(stats)

# Reformat data and toss out the extra, we're only worried about totals an
# the current week.
stats = []
for user in remote.contributor_stats():
# Assuming last entry is the current week to avoid having to calculate
# timesteps, etc.
this_week = user['weeks'][-1]

stats.append({'avatar_url': user['author']['avatar_url'],
'login': user['author']['login'],
'total': user['total'],
'weekly_commits': this_week['c'],
'weekly_additions': this_week['a'],
'weekly_deletions': this_week['d']})

if not stats:
return stats

stats = sorted(stats, key=lambda v: v['weekly_commits'], reverse=True)

# Just fetch stats every 30 minutes, this is not a critical bit of data
cache.save(cache_key, json.dumps(stats), timeout=30 * 60)

return stats
27 changes: 27 additions & 0 deletions pskb_website/remote.py
Expand Up @@ -645,3 +645,30 @@ def merge_branch(repo_path, base, head, message):

log_error('Failed merging', url, resp, repo=repo_path, base=base, head=head)
return False


def contributor_stats(repo_path=None):
"""
Get response of /repos/<repo_path>/stats/contributors from github.com
:param repo_path: Default repo or repo path in owner/repo_name form
:returns: Raw response of contributor stats from https://developer.github.com/v3/repos/statistics/#get-contributors-list-with-additions-deletions-and-commit-counts
Note the github caches contributor results so an empty list can also be
returned if the data is not available yet or there is an error
"""

repo_path = default_repo_path() if repo_path is None else repo_path
url = u'/repos/%s/stats/contributors' % (repo_path)

resp = github.get(url)

stats = []
if resp.status == 200:
stats = resp.data
elif resp.status == 202:
app.logger.info('Data not in cache from github.com')
else:
log_error('Failed reading stats from github', url, resp)

return stats
5 changes: 5 additions & 0 deletions pskb_website/static/css/base.css
Expand Up @@ -629,6 +629,11 @@ a.active-page {
font-size: 12px;
}

td.login-name {
padding-top: 15px !important;
white-space: nowrap;
}

@media(min-width:992px) {
.article-teaser .article-author span.gh-name {
right: 70px;
Expand Down
117 changes: 117 additions & 0 deletions pskb_website/templates/contributors.html
@@ -0,0 +1,117 @@
{% extends "layout.html" %}
{% block body %}

<h1 style="text-align: center;">Thanks for helping helping us create great guides</h1>

{% if commit_stats %}
<h2 style="text-align: center;">{{commit_stats|length}} authors have contributed so far!</h2>
{% endif %}

<h2>Community editors</h2>

<p>
Special thanks to the following community editors for helping improve the
quality of submitted guides:
</p>

<!-- Read this information from a editors.md file
<div class="list-group" style="text-align: center;">
<a href="https://github.com/prtkgpt" class="list-group-item" style="border: none;">
<img src="https://avatars.githubusercontent.com/u/2454349?v=3&amp;s=226" width="80" height="80" style="border-radius: 42px;" alt="prtkgpt"/>
<span class="list-group-item-heading">Prateek Gupta</h4>
<p class="list-group-item-text">Prateek's bio</p>
</a>
</div>
- Maybe list 3 users to a row or something.
- Use a table without borders ?
-->

<ul>
<li>Display editors.md information here</li>
</ul>

<hr>

<h3>Published leaderboard</h3>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th colspan="2">Author</th>
<th>Guides</th>
<th width="60%">&nbsp;</th>
</tr>
</thead>
<tbody>

{% for login, (count, avatar_url) in guide_stats|dictsort(by='value')|reverse %}
{% if login not in ignore_users %}
<tr>
{% if avatar_url %}
<td width="50"><img src="{{avatar_url}}&amp;s=126" width="40" height="40" style="border-radius: 22px;" alt="{{login}}"/></td>
{% else %}
<td width="50">&nbsp;</td>
{% endif %}

<td class="login-name"><a href="{{url_for('user_profile', author_name=login)}}">{{login}}</a></td>
<td>{{count}}</td>
<td width="60%">&nbsp;</td>
</tr>
{% endif %}
{% endfor %}

</tbody>
</table>
</div>


{% if not commit_stats %}
<p class="lead">Compiling detailed stats from Github.com, please check back soon.</p>
{% else %}

<hr>

<h4>Total contribution leaderboard</h4>
<p style="text-align: center;">
The table below is sorted by weekly contributions. So every week gives you
a fresh shot to get to the top.
</p>

<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th colspan="2">Author</th>
<th>Total commits</th>
<th>Commits this week</th>
<th>Additions</th>
<th>Deletions</th>
</tr>
</thead>
<tbody>

{# This is a list of dicts and the list is already sorted #}
{% for user in commit_stats %}
{% if user.login not in ignore_users %}
<tr>
{% if user.avatar_url %}
<td width="50"><img src="{{user.avatar_url}}&amp;s=126" width="40" height="40" style="border-radius: 22px;" alt="{{user.login}}"/></td>
{% else %}
<td width="50">&nbsp;</td>
{% endif %}

<td class="login-name"><a href="{{url_for('user_profile', author_name=user.login)}}">{{user.login}}</a></td>
<td>{{user.total}}</td>
<td>{{user.weekly_commits}}</td>
<td>{{user.weekly_additions}}</td>
<td>{{user.weekly_deletions}}</td>
</tr>
{% endif %}
{% endfor %}

</tbody>
</table>
</div>

{% endif %}
{% endblock %}
4 changes: 2 additions & 2 deletions pskb_website/templates/nav_links.html
Expand Up @@ -24,9 +24,10 @@

<li><a href="{{url_for('index')}}">Home</a></li>
<li><a href="{{url_for('in_review')}}">Review</a></li>
<li><a href="{{url_for('contributors')}}">Contributors</a></li>
<li><a href="{{url_for('faq')}}">Our Mission</a></li>

{% if session.github_token %}
<li><a href="{{url_for('faq')}}">Our Mission</a></li>
<li role="presentation" class="dropdown">
<a class="dropdown-toggle" data-toggle="dropdown" href="#" role="button" aria-haspopup="true" aria-expanded="false">
{% if session.user_image %}
Expand All @@ -43,7 +44,6 @@
</li>

{% else %}
<li><a href="{{url_for('faq')}}">Our Mission</a></li>
<li><a href="{{url_for('login')}}">Sign in with Github</a></li>
{% endif %}
</ul>
Expand Down
27 changes: 27 additions & 0 deletions pskb_website/views.py
Expand Up @@ -120,6 +120,33 @@ def gh_rate_limit():
return repr(remote.check_rate_limit())


@app.route('/contributors/')
def contributors():
"""Contributors page"""

commit_stats = models.weekly_contribution_stats()
guide_stats = models.author_stats(statuses=(PUBLISHED,))

# FIXME: Would be better to automatically ignore all collaborators on a
# repo but that requires 1 API request per user and we might want to count
# some collaborators and not others anyway.

# We could pass this ignore_users down but then we'd have to be mindful of
# which version was cached, etc. It's easier to do this trimming here
# because we can trim all stats independent of lower layers and caching
# even though this might not be as efficient. Ideally we won't be
# ignoring large amounts of users so shouldn't be a big issue.

ignore_users = []
for user in app.config.get('IGNORE_STATS_FOR', '').split(','):
ignore_users.append(user.strip())

return render_template('contributors.html',
commit_stats=commit_stats,
guide_stats=guide_stats,
ignore_users=ignore_users)


@app.route('/faq/')
def faq():
"""FAQ page"""
Expand Down

0 comments on commit 10bd2c6

Please sign in to comment.