зеркало из https://github.com/mozilla/kitsune.git
A start to karma implementation with redis backend.
* Karma actions defined for answer, first answer, solution, helpful vote * Behind 'karma' waffle switch * /admin/karma page shows top contributors, allows user key lookup, and initializing karma. * REDIS_TEST_BACKENDS settings used for tests * SkipTest tests that depend on redis when the test backend(s) arent defined * Added redis-test.conf
This commit is contained in:
Родитель
03d14c012d
Коммит
44e96f0de4
|
@ -0,0 +1,189 @@
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
from celery.decorators import task
|
||||||
|
import waffle
|
||||||
|
|
||||||
|
from sumo.utils import redis_client
|
||||||
|
|
||||||
|
|
||||||
|
KEY_PREFIX = 'karma' # Prefix for the Redis keys used.
|
||||||
|
|
||||||
|
|
||||||
|
class KarmaAction(object):
|
||||||
|
"""Abstract base class for karma actions."""
|
||||||
|
action_type = None # For example 'first-answer'.
|
||||||
|
points = 0 # Number of points the action is worth.
|
||||||
|
|
||||||
|
def __init__(self, user, day=date.today(), redis=None):
|
||||||
|
if not waffle.switch_is_active('karma'):
|
||||||
|
return
|
||||||
|
if isinstance(user, User):
|
||||||
|
self.userid = user.id
|
||||||
|
else:
|
||||||
|
self.userid = user
|
||||||
|
if isinstance(day, datetime): # Gracefully handle a datetime.
|
||||||
|
self.date = day.date()
|
||||||
|
else:
|
||||||
|
self.date = day
|
||||||
|
if not redis:
|
||||||
|
self.redis = redis_client(name='karma')
|
||||||
|
else:
|
||||||
|
self.redis = redis
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
"""Save the action information to redis."""
|
||||||
|
if waffle.switch_is_active('karma'):
|
||||||
|
self._save.delay(self)
|
||||||
|
|
||||||
|
@task
|
||||||
|
def _save(self):
|
||||||
|
key = hash_key(self.userid)
|
||||||
|
|
||||||
|
# Point counters:
|
||||||
|
# Increment total points
|
||||||
|
self.redis.hincrby(key, 'points:total', self.points)
|
||||||
|
# Increment points daily count
|
||||||
|
self.redis.hincrby(key, 'points:{d}'.format(
|
||||||
|
d=self.date), self.points)
|
||||||
|
# Increment points monthly count
|
||||||
|
self.redis.hincrby(key, 'points:{y}-{m:02d}'.format(
|
||||||
|
y=self.date.year, m=self.date.month), self.points)
|
||||||
|
# Increment points yearly count
|
||||||
|
self.redis.hincrby(key, 'points:{y}'.format(
|
||||||
|
y=self.date.year), self.points)
|
||||||
|
|
||||||
|
# Action counters:
|
||||||
|
# Increment action total count
|
||||||
|
self.redis.hincrby(key, '{t}:total'.format(t=self.action_type), 1)
|
||||||
|
# Increment action daily count
|
||||||
|
self.redis.hincrby(key, '{t}:{d}'.format(
|
||||||
|
t=self.action_type, d=self.date), 1)
|
||||||
|
# Increment action monthly count
|
||||||
|
self.redis.hincrby(key, '{t}:{y}-{m:02d}'.format(
|
||||||
|
t=self.action_type, y=self.date.year, m=self.date.month), 1)
|
||||||
|
# Increment action yearly count
|
||||||
|
self.redis.hincrby(key, '{t}:{y}'.format(
|
||||||
|
t=self.action_type, y=self.date.year), 1)
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: move this to it's own file?
|
||||||
|
class KarmaManager(object):
|
||||||
|
"""Manager for querying karma data in Redis."""
|
||||||
|
def __init__(self):
|
||||||
|
self.redis = redis_client(name='karma')
|
||||||
|
|
||||||
|
# Updaters:
|
||||||
|
def update_top_alltime(self):
|
||||||
|
"""Updated the top contributors alltime sorted set."""
|
||||||
|
key = '{p}:points:total'.format(p=KEY_PREFIX)
|
||||||
|
# TODO: Maintain a user id list in Redis?
|
||||||
|
for userid in User.objects.values_list('id', flat=True):
|
||||||
|
pts = self.total_points(userid)
|
||||||
|
if pts:
|
||||||
|
self.redis.zadd(key, userid, pts)
|
||||||
|
|
||||||
|
def update_top_week(self):
|
||||||
|
"""Updated the top contributors past week sorted set."""
|
||||||
|
key = '{p}:points:week'.format(p=KEY_PREFIX)
|
||||||
|
for userid in User.objects.values_list('id', flat=True):
|
||||||
|
pts = self.week_points(userid)
|
||||||
|
if pts:
|
||||||
|
self.redis.zadd(key, userid, pts)
|
||||||
|
|
||||||
|
# def update_trending...
|
||||||
|
|
||||||
|
# Getters:
|
||||||
|
def top_alltime(self, count=10):
|
||||||
|
"""Returns the top users based on alltime points."""
|
||||||
|
return self._top_points(count, 'total')
|
||||||
|
|
||||||
|
def top_week(self, count=10):
|
||||||
|
"""Returns the top users based on points in the last 7 days."""
|
||||||
|
return self._top_points(count, 'week')
|
||||||
|
|
||||||
|
def _top_points(self, count, suffix):
|
||||||
|
ids = self.redis.zrevrange('{p}:points:{s}'.format(
|
||||||
|
p=KEY_PREFIX, s=suffix), 0, count - 1)
|
||||||
|
users = list(User.objects.filter(id__in=ids))
|
||||||
|
users.sort(key=lambda user: ids.index(str(user.id)))
|
||||||
|
return users
|
||||||
|
|
||||||
|
def total_points(self, user):
|
||||||
|
"""Returns the total points for a given user."""
|
||||||
|
count = self.redis.hget(hash_key(user), 'points:total')
|
||||||
|
return int(count) if count else 0
|
||||||
|
|
||||||
|
def week_points(self, user):
|
||||||
|
"""Returns total points from the last 7 days for a given user."""
|
||||||
|
today = date.today()
|
||||||
|
days = [today - timedelta(days=d + 1) for d in range(7)]
|
||||||
|
counts = self.redis.hmget(hash_key(user),
|
||||||
|
['points:{d}'.format(d=d) for d in days])
|
||||||
|
fn = lambda x: int(x) if x else 0
|
||||||
|
count = sum([fn(c) for c in counts])
|
||||||
|
return count
|
||||||
|
|
||||||
|
def daily_points(self, user, days_back=30):
|
||||||
|
"""Returns a list of points from the past `days_back` days."""
|
||||||
|
today = date.today()
|
||||||
|
days = [today - timedelta(days=d) for d in range(days_back)]
|
||||||
|
counts = self.redis.hmget(hash_key(user),
|
||||||
|
['points:{d}'.format(d=d) for d in days])
|
||||||
|
fn = lambda x: int(x) if x else 0
|
||||||
|
return [fn(c) for c in counts]
|
||||||
|
|
||||||
|
def monthly_points(self, user, months_back=12):
|
||||||
|
"""Returns a list of points from the past `months_back` months."""
|
||||||
|
# TODO: Tricky?
|
||||||
|
pass
|
||||||
|
|
||||||
|
def total_count(self, action, user):
|
||||||
|
"""Returns the total count of an action for a given user."""
|
||||||
|
count = self.redis.hget(
|
||||||
|
hash_key(user), '{t}:total'.format(t=action.action_type))
|
||||||
|
return int(count) if count else 0
|
||||||
|
|
||||||
|
def day_count(self, action, user, date=date.today()):
|
||||||
|
"""Returns the total count of an action for a given user and day."""
|
||||||
|
count = self.redis.hget(
|
||||||
|
hash_key(user), '{t}:{d}'.format(d=date, t=action.action_type))
|
||||||
|
return int(count) if count else 0
|
||||||
|
|
||||||
|
def week_count(self, action, user):
|
||||||
|
"""Returns total count of an action for a given user (last 7 days)."""
|
||||||
|
# TODO: DRY this up with week_points and daily_points.
|
||||||
|
today = date.today()
|
||||||
|
days = [today - timedelta(days=d + 1) for d in range(7)]
|
||||||
|
counts = self.redis.hmget(hash_key(user), ['{t}:{d}'.format(
|
||||||
|
t=action.action_type, d=d) for d in days])
|
||||||
|
fn = lambda x: int(x) if x else 0
|
||||||
|
count = sum([fn(c) for c in counts])
|
||||||
|
return count
|
||||||
|
|
||||||
|
def month_count(self, action, user, year, month):
|
||||||
|
"""Returns the total count of an action for a given user and month."""
|
||||||
|
count = self.redis.hget(
|
||||||
|
hash_key(user),
|
||||||
|
'{t}:{y}-{m:02d}'.format(t=action.action_type, y=year, m=month))
|
||||||
|
return int(count) if count else 0
|
||||||
|
|
||||||
|
def year_count(self, action, user, year):
|
||||||
|
"""Returns the total count of an action for a given user and year."""
|
||||||
|
count = self.redis.hget(
|
||||||
|
hash_key(user), '{t}:{y}'.format(y=year, t=action.action_type))
|
||||||
|
return int(count) if count else 0
|
||||||
|
|
||||||
|
def user_data(self, user):
|
||||||
|
"""Returns all the data stored for the given user."""
|
||||||
|
return self.redis.hgetall(hash_key(user))
|
||||||
|
|
||||||
|
|
||||||
|
def hash_key(user):
|
||||||
|
"""Returns the hash key for a given user."""
|
||||||
|
if isinstance(user, User):
|
||||||
|
userid = user.id
|
||||||
|
else:
|
||||||
|
userid = user
|
||||||
|
return "{p}:{u}".format(p=KEY_PREFIX, u=userid)
|
|
@ -0,0 +1,71 @@
|
||||||
|
from django.contrib import admin, messages
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
|
from django.shortcuts import render_to_response
|
||||||
|
from django.template import RequestContext
|
||||||
|
|
||||||
|
from karma.actions import KarmaManager
|
||||||
|
from karma.tasks import init_karma, update_top_contributors
|
||||||
|
from questions.karma_actions import (AnswerAction, AnswerMarkedHelpfulAction,
|
||||||
|
FirstAnswerAction, SolutionAction)
|
||||||
|
|
||||||
|
|
||||||
|
def karma(request):
|
||||||
|
"""Admin view that displays karma related data."""
|
||||||
|
if request.POST.get('init'):
|
||||||
|
init_karma.delay()
|
||||||
|
messages.add_message(request, messages.SUCCESS,
|
||||||
|
'init_karma task queued!')
|
||||||
|
return HttpResponseRedirect(request.path)
|
||||||
|
|
||||||
|
if request.POST.get('update-top'):
|
||||||
|
update_top_contributors.delay()
|
||||||
|
messages.add_message(request, messages.SUCCESS,
|
||||||
|
'update_top_contributors task queued!')
|
||||||
|
return HttpResponseRedirect(request.path)
|
||||||
|
|
||||||
|
kmgr = KarmaManager()
|
||||||
|
top_alltime = [_user_karma_alltime(u, kmgr) for u in kmgr.top_alltime()]
|
||||||
|
top_week = [_user_karma_week(u, kmgr) for u in kmgr.top_week()]
|
||||||
|
|
||||||
|
username = request.GET.get('username')
|
||||||
|
user_karma = None
|
||||||
|
if username:
|
||||||
|
try:
|
||||||
|
user = User.objects.get(username=username)
|
||||||
|
d = kmgr.user_data(user)
|
||||||
|
user_karma = [{'key': k, 'value': d[k]} for k in sorted(d.keys())]
|
||||||
|
except User.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return render_to_response('karma/admin/karma.html',
|
||||||
|
{'title': 'Karma',
|
||||||
|
'top_alltime': top_alltime,
|
||||||
|
'top_week': top_week,
|
||||||
|
'username': username,
|
||||||
|
'user_karma': user_karma},
|
||||||
|
RequestContext(request, {}))
|
||||||
|
|
||||||
|
admin.site.register_view('karma', karma, 'Karma')
|
||||||
|
|
||||||
|
|
||||||
|
def _user_karma_alltime(user, kmgr):
|
||||||
|
return {
|
||||||
|
'user': user,
|
||||||
|
'points': kmgr.total_points(user),
|
||||||
|
'answers': kmgr.total_count(AnswerAction, user),
|
||||||
|
'first_answers': kmgr.total_count(FirstAnswerAction, user),
|
||||||
|
'helpful_votes': kmgr.total_count(AnswerMarkedHelpfulAction, user),
|
||||||
|
'solutions': kmgr.total_count(SolutionAction, user),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _user_karma_week(user, kmgr):
|
||||||
|
return {
|
||||||
|
'user': user,
|
||||||
|
'points': kmgr.week_points(user),
|
||||||
|
'answers': kmgr.week_count(AnswerAction, user),
|
||||||
|
'first_answers': kmgr.week_count(FirstAnswerAction, user),
|
||||||
|
'helpful_votes': kmgr.week_count(AnswerMarkedHelpfulAction, user),
|
||||||
|
'solutions': kmgr.week_count(SolutionAction, user),
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import cronjobs
|
||||||
|
import waffle
|
||||||
|
|
||||||
|
from karma.actions import KarmaManager
|
||||||
|
|
||||||
|
|
||||||
|
@cronjobs.register
|
||||||
|
def update_top_contributors():
|
||||||
|
""""Update the top contributor lists"""
|
||||||
|
if not waffle.switch_is_active('karma'):
|
||||||
|
return
|
||||||
|
|
||||||
|
kmgr = KarmaManager()
|
||||||
|
kmgr.update_top_alltime()
|
||||||
|
kmgr.update_top_week()
|
|
@ -0,0 +1 @@
|
||||||
|
# Everything is in REDIS
|
|
@ -0,0 +1,63 @@
|
||||||
|
from celery.decorators import task
|
||||||
|
import waffle
|
||||||
|
|
||||||
|
from karma.actions import redis_client
|
||||||
|
from karma.cron import update_top_contributors as _update_top_contributors
|
||||||
|
from questions.karma_actions import (AnswerAction, AnswerMarkedHelpfulAction,
|
||||||
|
FirstAnswerAction, SolutionAction)
|
||||||
|
from questions.models import Question, AnswerVote
|
||||||
|
from sumo.utils import chunked
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def init_karma():
|
||||||
|
"""Flushes the karma redis backend and populates with fresh data.
|
||||||
|
|
||||||
|
Goes through all questions/answers/votes and save karma actions for them.
|
||||||
|
"""
|
||||||
|
if not waffle.switch_is_active('karma'):
|
||||||
|
return
|
||||||
|
|
||||||
|
redis_client('karma').flushdb()
|
||||||
|
|
||||||
|
questions = Question.objects.all()
|
||||||
|
for chunk in chunked(questions.values_list('pk', flat=True), 200):
|
||||||
|
_process_question_chunk.apply_async(args=[chunk])
|
||||||
|
|
||||||
|
votes = AnswerVote.objects.filter(helpful=True)
|
||||||
|
for chunk in chunked(votes.values_list('pk', flat=True), 1000):
|
||||||
|
_process_answer_vote_chunk.apply_async(args=[chunk])
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def update_top_contributors():
|
||||||
|
"""Updates the top contributor sorted sets."""
|
||||||
|
_update_top_contributors()
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def _process_question_chunk(data, **kwargs):
|
||||||
|
"""Save karma data for a chunk of questions."""
|
||||||
|
redis = redis_client('karma')
|
||||||
|
q_qs = Question.objects.select_related('solution').defer('content')
|
||||||
|
for question in q_qs.filter(pk__in=data):
|
||||||
|
first = True
|
||||||
|
a_qs = question.answers.order_by('created').select_related('creator')
|
||||||
|
for answer in a_qs.values_list('creator', 'created'):
|
||||||
|
AnswerAction(answer[0], answer[1], redis).save()
|
||||||
|
if first:
|
||||||
|
FirstAnswerAction(answer[0], answer[1], redis).save()
|
||||||
|
first = False
|
||||||
|
soln = question.solution
|
||||||
|
if soln:
|
||||||
|
SolutionAction(soln.creator, soln.created, redis).save()
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def _process_answer_vote_chunk(data, **kwargs):
|
||||||
|
"""Save karma data for a chunk of answer votes."""
|
||||||
|
redis = redis_client('karma')
|
||||||
|
v_qs = AnswerVote.objects.select_related('answer')
|
||||||
|
for vote in v_qs.filter(pk__in=data):
|
||||||
|
AnswerMarkedHelpfulAction(
|
||||||
|
vote.answer.creator_id, vote.created, redis).save()
|
|
@ -0,0 +1,94 @@
|
||||||
|
{% extends "kadmin/base.html" %}
|
||||||
|
{% load waffle_tags %}
|
||||||
|
|
||||||
|
{% block extrastyle %}
|
||||||
|
{{ block.super }}
|
||||||
|
<link rel="stylesheet" media="screen,projection,tv" href="{{ MEDIA_URL }}css/users.autocomplete.css" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% switch 'karma' %}
|
||||||
|
{% else %}
|
||||||
|
<p>Karma is currently disabled. Activate waffle switch 'karma' to enable.</p>
|
||||||
|
{% endswitch %}
|
||||||
|
<section>
|
||||||
|
<h1>Top Contributors - All Time</h1>
|
||||||
|
<ol>
|
||||||
|
{% for user in top_alltime %}
|
||||||
|
<li>
|
||||||
|
<a href="#">{{ user.user.username }}</a>:
|
||||||
|
Points: {{ user.points }} |
|
||||||
|
Answers: {{ user.answers }} |
|
||||||
|
First Answers: {{ user.first_answers }} |
|
||||||
|
Solutions: {{ user.solutions }} |
|
||||||
|
Helpful Votes: {{ user.helpful_votes }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h1>Top Contributors - Last 7 Days</h1>
|
||||||
|
<ol>
|
||||||
|
{% for user in top_week %}
|
||||||
|
<li>
|
||||||
|
<a href="#">{{ user.user.username }}</a>:
|
||||||
|
Points: {{ user.points }} |
|
||||||
|
Answers: {{ user.answers }} |
|
||||||
|
First Answers: {{ user.first_answers }} |
|
||||||
|
Solutions: {{ user.solutions }} |
|
||||||
|
Helpful Votes: {{ user.helpful_votes }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<form action="" method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="update-top" value="1" />
|
||||||
|
<input type="submit" value="Update Top Contributors" />
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h1>User Karma{% if username %}: {{ username }}{% endif %}</h1>
|
||||||
|
<form action="" method="GET">
|
||||||
|
<input type="text" placeholder="username" class="user-autocomplete" name="username" />
|
||||||
|
<input type="submit" value="Show Karma Data" />
|
||||||
|
</form>
|
||||||
|
{% if user_karma %}
|
||||||
|
<table class="redis-info">
|
||||||
|
<tbody>
|
||||||
|
{% for row in user_karma %}
|
||||||
|
<tr>
|
||||||
|
<th>{{ row.key }}</th>
|
||||||
|
<td>{{ row.value }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
{% if username %}
|
||||||
|
<p>User or karma data not found.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h1>Initialize Karma</h1>
|
||||||
|
<p>Warning: This will launch a task to delete all existing karma data from redis and recalculate from the database.</p>
|
||||||
|
<form action="" method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="init" value="1" />
|
||||||
|
<input type="submit" value="Init Karma" />
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block footer %}
|
||||||
|
{{ block.super }}
|
||||||
|
<script type="text/javascript" src="{{ MEDIA_URL }}js/libs/jquery.min.js"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
// Not happy about this... but it works!
|
||||||
|
$('body').data('usernames-api', '/en-US/users/api/usernames');
|
||||||
|
</script>
|
||||||
|
<script type="text/javascript" src="{{ MEDIA_URL }}js/libs/jquery.autocomplete.js"></script>
|
||||||
|
<script type="text/javascript" src="{{ MEDIA_URL }}js/users.autocomplete.js"></script>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,66 @@
|
||||||
|
from django.conf import settings
|
||||||
|
from datetime import date
|
||||||
|
import mock
|
||||||
|
from nose import SkipTest
|
||||||
|
from nose.tools import eq_
|
||||||
|
import waffle
|
||||||
|
|
||||||
|
from karma.actions import KarmaAction, KarmaManager, redis_client
|
||||||
|
from sumo.tests import TestCase
|
||||||
|
from users.tests import user
|
||||||
|
|
||||||
|
|
||||||
|
class TestAction1(KarmaAction):
|
||||||
|
"""A test action for testing!"""
|
||||||
|
action_type = 'test-action-1'
|
||||||
|
points = 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestAction2(KarmaAction):
|
||||||
|
"""Another test action for testing!"""
|
||||||
|
action_type = 'test-action-2'
|
||||||
|
points = 7
|
||||||
|
|
||||||
|
|
||||||
|
class KarmaActionTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super(KarmaActionTests, self).setUp()
|
||||||
|
self.user = user(save=True)
|
||||||
|
try:
|
||||||
|
self.mgr = KarmaManager()
|
||||||
|
redis_client('karma').flushdb()
|
||||||
|
except (KeyError, AttributeError):
|
||||||
|
raise SkipTest
|
||||||
|
|
||||||
|
@mock.patch.object(waffle, 'switch_is_active')
|
||||||
|
def test_action(self, switch_is_active):
|
||||||
|
"""Save an action and verify."""
|
||||||
|
switch_is_active.return_value = True
|
||||||
|
TestAction1(user=self.user).save()
|
||||||
|
eq_(3, self.mgr.total_points(self.user))
|
||||||
|
eq_(1, self.mgr.total_count(TestAction1, self.user))
|
||||||
|
today = date.today()
|
||||||
|
eq_(1, self.mgr.day_count(TestAction1, self.user, today))
|
||||||
|
eq_(1, self.mgr.month_count(TestAction1, self.user, today.year,
|
||||||
|
today.month))
|
||||||
|
eq_(1, self.mgr.year_count(TestAction1, self.user, today.year))
|
||||||
|
|
||||||
|
@mock.patch.object(waffle, 'switch_is_active')
|
||||||
|
def test_two_actions(self, switch_is_active):
|
||||||
|
"""Save two actions, one twice, and verify."""
|
||||||
|
switch_is_active.return_value = True
|
||||||
|
TestAction1(user=self.user).save()
|
||||||
|
TestAction2(user=self.user).save()
|
||||||
|
TestAction2(user=self.user).save()
|
||||||
|
eq_(17, self.mgr.total_points(self.user))
|
||||||
|
eq_(1, self.mgr.total_count(TestAction1, self.user))
|
||||||
|
eq_(2, self.mgr.total_count(TestAction2, self.user))
|
||||||
|
today = date.today()
|
||||||
|
eq_(1, self.mgr.day_count(TestAction1, self.user, today))
|
||||||
|
eq_(1, self.mgr.month_count(TestAction1, self.user, today.year,
|
||||||
|
today.month))
|
||||||
|
eq_(1, self.mgr.year_count(TestAction1, self.user, today.year))
|
||||||
|
eq_(2, self.mgr.day_count(TestAction2, self.user, today))
|
||||||
|
eq_(2, self.mgr.month_count(TestAction2, self.user, today.year,
|
||||||
|
today.month))
|
||||||
|
eq_(2, self.mgr.year_count(TestAction2, self.user, today.year))
|
|
@ -25,6 +25,7 @@ def update_weekly_votes():
|
||||||
update_question_vote_chunk.apply_async(args=[chunk])
|
update_question_vote_chunk.apply_async(args=[chunk])
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: remove this and use the karma top list.
|
||||||
@cronjobs.register
|
@cronjobs.register
|
||||||
def cache_top_contributors():
|
def cache_top_contributors():
|
||||||
"""Compute the top contributors and store in cache."""
|
"""Compute the top contributors and store in cache."""
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
from karma.actions import KarmaAction
|
||||||
|
|
||||||
|
|
||||||
|
class AnswerAction(KarmaAction):
|
||||||
|
"""The user posted an answer."""
|
||||||
|
action_type = 'answer'
|
||||||
|
points = 1
|
||||||
|
|
||||||
|
|
||||||
|
class FirstAnswerAction(KarmaAction):
|
||||||
|
"""The user posted the first answer to a question."""
|
||||||
|
action_type = 'first-answer'
|
||||||
|
points = 5
|
||||||
|
|
||||||
|
|
||||||
|
class AnswerMarkedHelpfulAction(KarmaAction):
|
||||||
|
"""The user's answer was voted as helpful."""
|
||||||
|
action_type = 'helpful-answer'
|
||||||
|
points = 10
|
||||||
|
|
||||||
|
|
||||||
|
class SolutionAction(KarmaAction):
|
||||||
|
"""The user's answer was marked as the solution."""
|
||||||
|
action_type = 'solution'
|
||||||
|
points = 25
|
|
@ -8,6 +8,7 @@ from statsd import statsd
|
||||||
|
|
||||||
from activity.models import Action
|
from activity.models import Action
|
||||||
from questions import ANSWERS_PER_PAGE
|
from questions import ANSWERS_PER_PAGE
|
||||||
|
from questions.karma_actions import AnswerAction, FirstAnswerAction
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger('k.task')
|
log = logging.getLogger('k.task')
|
||||||
|
@ -79,3 +80,8 @@ def log_answer(answer):
|
||||||
|
|
||||||
transaction.commit_unless_managed()
|
transaction.commit_unless_managed()
|
||||||
unpin_this_thread()
|
unpin_this_thread()
|
||||||
|
|
||||||
|
# Record karma actions
|
||||||
|
AnswerAction(answer.creator, answer.created.date()).save()
|
||||||
|
if answer == answer.question.answers.order_by('created')[0]:
|
||||||
|
FirstAnswerAction(answer.creator, answer.created.date()).save()
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
import mock
|
||||||
|
|
||||||
|
from questions.karma_actions import (AnswerAction, AnswerMarkedHelpfulAction,
|
||||||
|
FirstAnswerAction, SolutionAction)
|
||||||
|
from questions.models import Question, Answer
|
||||||
|
from questions.tests import TestCaseBase
|
||||||
|
from sumo.tests import post
|
||||||
|
from users.tests import user
|
||||||
|
|
||||||
|
|
||||||
|
class KarmaTests(TestCaseBase):
|
||||||
|
"""Tests for karma actions."""
|
||||||
|
def setUp(self):
|
||||||
|
super(KarmaTests, self).setUp()
|
||||||
|
self.user = user(save=True)
|
||||||
|
|
||||||
|
@mock.patch.object(AnswerAction, 'save')
|
||||||
|
@mock.patch.object(FirstAnswerAction, 'save')
|
||||||
|
def test_new_answer(self, first, answer):
|
||||||
|
question = Question.objects.all()[0]
|
||||||
|
Answer.objects.create(question=question, creator=self.user)
|
||||||
|
assert answer.called
|
||||||
|
assert not first.called
|
||||||
|
|
||||||
|
@mock.patch.object(AnswerAction, 'save')
|
||||||
|
@mock.patch.object(FirstAnswerAction, 'save')
|
||||||
|
def test_first_answer(self, first, answer):
|
||||||
|
question = Question.objects.all()[1]
|
||||||
|
Answer.objects.create(question=question, creator=self.user)
|
||||||
|
assert answer.called
|
||||||
|
assert first.called
|
||||||
|
|
||||||
|
@mock.patch.object(SolutionAction, 'save')
|
||||||
|
def test_solution(self, save):
|
||||||
|
answer = Answer.objects.get(pk=1)
|
||||||
|
question = answer.question
|
||||||
|
self.client.login(username='jsocol', password='testpass')
|
||||||
|
post(self.client, 'questions.solve', args=[question.id, answer.id])
|
||||||
|
assert save.called
|
||||||
|
|
||||||
|
@mock.patch.object(AnswerMarkedHelpfulAction, 'save')
|
||||||
|
def test_helpful_vote(self, save):
|
||||||
|
answer = Answer.objects.get(pk=1)
|
||||||
|
question = answer.question
|
||||||
|
post(self.client, 'questions.answer_vote', {'helpful': True},
|
||||||
|
args=[question.id, answer.id])
|
||||||
|
assert save.called
|
|
@ -35,6 +35,7 @@ from questions.events import QuestionReplyEvent, QuestionSolvedEvent
|
||||||
from questions.feeds import QuestionsFeed, AnswersFeed, TaggedQuestionsFeed
|
from questions.feeds import QuestionsFeed, AnswersFeed, TaggedQuestionsFeed
|
||||||
from questions.forms import (NewQuestionForm, EditQuestionForm, AnswerForm,
|
from questions.forms import (NewQuestionForm, EditQuestionForm, AnswerForm,
|
||||||
WatchQuestionForm, FREQUENCY_CHOICES)
|
WatchQuestionForm, FREQUENCY_CHOICES)
|
||||||
|
from questions.karma_actions import SolutionAction, AnswerMarkedHelpfulAction
|
||||||
from questions.models import Question, Answer, QuestionVote, AnswerVote
|
from questions.models import Question, Answer, QuestionVote, AnswerVote
|
||||||
from questions.question_config import products
|
from questions.question_config import products
|
||||||
from search.clients import WikiClient, QuestionsClient, SearchError
|
from search.clients import WikiClient, QuestionsClient, SearchError
|
||||||
|
@ -389,7 +390,7 @@ def solve(request, question_id, answer_id):
|
||||||
question.save()
|
question.save()
|
||||||
statsd.incr('questions.solution')
|
statsd.incr('questions.solution')
|
||||||
QuestionSolvedEvent(answer).fire(exclude=question.creator)
|
QuestionSolvedEvent(answer).fire(exclude=question.creator)
|
||||||
|
SolutionAction(answer.creator).save()
|
||||||
messages.add_message(request, messages.SUCCESS,
|
messages.add_message(request, messages.SUCCESS,
|
||||||
_('Thank you for choosing a solution!'))
|
_('Thank you for choosing a solution!'))
|
||||||
|
|
||||||
|
@ -460,6 +461,7 @@ def answer_vote(request, question_id, answer_id):
|
||||||
|
|
||||||
if 'helpful' in request.POST:
|
if 'helpful' in request.POST:
|
||||||
vote.helpful = True
|
vote.helpful = True
|
||||||
|
AnswerMarkedHelpfulAction(answer.creator).save()
|
||||||
message = _('Glad to hear it!')
|
message = _('Glad to hear it!')
|
||||||
else:
|
else:
|
||||||
message = _('Sorry to hear that.')
|
message = _('Sorry to hear that.')
|
||||||
|
|
|
@ -8,7 +8,7 @@ from django.conf import settings
|
||||||
from django.test.client import Client
|
from django.test.client import Client
|
||||||
|
|
||||||
from nose.tools import eq_
|
from nose.tools import eq_
|
||||||
from test_utils import TestCase # So others can import it from here
|
import test_utils
|
||||||
|
|
||||||
import sumo
|
import sumo
|
||||||
from sumo.urlresolvers import reverse, split_path
|
from sumo.urlresolvers import reverse, split_path
|
||||||
|
@ -63,6 +63,12 @@ class LocalizingClient(Client):
|
||||||
# prepending in a one-off case or do it outside a mock request.
|
# prepending in a one-off case or do it outside a mock request.
|
||||||
|
|
||||||
|
|
||||||
|
class TestCase(test_utils.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super(TestCase, self).setUp()
|
||||||
|
settings.REDIS_BACKENDS = settings.REDIS_TEST_BACKENDS
|
||||||
|
|
||||||
|
|
||||||
class MigrationTests(TestCase):
|
class MigrationTests(TestCase):
|
||||||
"""Sanity checks for the SQL migration scripts"""
|
"""Sanity checks for the SQL migration scripts"""
|
||||||
|
|
||||||
|
@ -114,6 +120,7 @@ class MigrationTests(TestCase):
|
||||||
class MobileTestCase(TestCase):
|
class MobileTestCase(TestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
super(MobileTestCase, self).setUp()
|
||||||
self.client.cookies[settings.MOBILE_COOKIE] = 'on'
|
self.client.cookies[settings.MOBILE_COOKIE] = 'on'
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
daemonize yes
|
||||||
|
pidfile /var/run/redis/redis-sumo-test.pid
|
||||||
|
port 6383
|
||||||
|
timeout 300
|
||||||
|
loglevel verbose
|
||||||
|
logfile stdout
|
||||||
|
databases 4
|
||||||
|
rdbcompression yes
|
||||||
|
dbfilename /var/redis/sumo-test/dump.rdb
|
||||||
|
dir /var/redis/sumo-test/
|
||||||
|
maxmemory 15032385536
|
||||||
|
maxmemory-policy allkeys-lru
|
||||||
|
appendonly no
|
||||||
|
appendfsync everysec
|
||||||
|
vm-enabled no
|
||||||
|
vm-swap-file /tmp/redis-sumo-test.swap
|
||||||
|
vm-max-memory 0
|
||||||
|
vm-page-size 32
|
||||||
|
vm-pages 134217728
|
||||||
|
vm-max-threads 4
|
||||||
|
hash-max-zipmap-entries 64
|
||||||
|
hash-max-zipmap-value 512
|
||||||
|
activerehashing yes
|
|
@ -22,3 +22,8 @@ h2 {
|
||||||
#settings tr:hover th a {
|
#settings tr:hover th a {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#content section {
|
||||||
|
border-bottom: solid 2px #f0f0f0;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
|
@ -69,6 +69,7 @@ HOME = /tmp
|
||||||
# Once per day.
|
# Once per day.
|
||||||
0 16 * * * $CRON reload_wiki_traffic_stats
|
0 16 * * * $CRON reload_wiki_traffic_stats
|
||||||
40 1 * * * $CRON update_weekly_votes
|
40 1 * * * $CRON update_weekly_votes
|
||||||
|
0 42 * * * $CRON update_top_contributors
|
||||||
|
|
||||||
# Twice per week.
|
# Twice per week.
|
||||||
#05 01 * * 1,4 $CRON update_weekly_votes
|
#05 01 * * 1,4 $CRON update_weekly_votes
|
||||||
|
|
|
@ -26,6 +26,7 @@ HOME = /tmp
|
||||||
# Once per day.
|
# Once per day.
|
||||||
0 16 * * * cd /data/www/support.mozilla.com/kitsune; /usr/bin/python26 manage.py cron reload_wiki_traffic_stats
|
0 16 * * * cd /data/www/support.mozilla.com/kitsune; /usr/bin/python26 manage.py cron reload_wiki_traffic_stats
|
||||||
40 1 * * * cd /data/www/support.mozilla.com/kitsune; /usr/bin/python26 manage.py cron update_weekly_votes
|
40 1 * * * cd /data/www/support.mozilla.com/kitsune; /usr/bin/python26 manage.py cron update_weekly_votes
|
||||||
|
0 42 * * * cd /data/www/support.mozilla.com/kitsune; /usr/bin/python26 manage.py cron update_top_contributors
|
||||||
|
|
||||||
# Twice per week.
|
# Twice per week.
|
||||||
#05 01 * * 1,4 cd /data/www/support.mozilla.com/kitsune; /usr/bin/python26 manage.py cron update_weekly_votes
|
#05 01 * * 1,4 cd /data/www/support.mozilla.com/kitsune; /usr/bin/python26 manage.py cron update_weekly_votes
|
||||||
|
|
|
@ -26,6 +26,7 @@ HOME = /tmp
|
||||||
# Once per day.
|
# Once per day.
|
||||||
0 16 * * * cd /data/www/support.allizom.org/kitsune; /usr/bin/python26 manage.py cron reload_wiki_traffic_stats
|
0 16 * * * cd /data/www/support.allizom.org/kitsune; /usr/bin/python26 manage.py cron reload_wiki_traffic_stats
|
||||||
40 1 * * * cd /data/www/support.allizom.org/kitsune; /usr/bin/python26 manage.py cron update_weekly_votes
|
40 1 * * * cd /data/www/support.allizom.org/kitsune; /usr/bin/python26 manage.py cron update_weekly_votes
|
||||||
|
0 42 * * * cd /data/www/support.allizom.org/kitsune; /usr/bin/python26 manage.py cron update_top_contributors
|
||||||
|
|
||||||
# Twice per week.
|
# Twice per week.
|
||||||
#05 01 * * 1,4 cd /data/www/support.allizom.org/kitsune; /usr/bin/python26 manage.py cron update_weekly_votes
|
#05 01 * * 1,4 cd /data/www/support.allizom.org/kitsune; /usr/bin/python26 manage.py cron update_weekly_votes
|
||||||
|
|
|
@ -26,6 +26,7 @@ HOME = /tmp
|
||||||
# Once per day.
|
# Once per day.
|
||||||
0 16 * * * cd /data/www/support-release.allizom.org/kitsune; /usr/bin/python26 manage.py cron reload_wiki_traffic_stats
|
0 16 * * * cd /data/www/support-release.allizom.org/kitsune; /usr/bin/python26 manage.py cron reload_wiki_traffic_stats
|
||||||
40 1 * * * cd /data/www/support-release.allizom.org/kitsune; /usr/bin/python26 manage.py cron update_weekly_votes
|
40 1 * * * cd /data/www/support-release.allizom.org/kitsune; /usr/bin/python26 manage.py cron update_weekly_votes
|
||||||
|
0 42 * * * cd /data/www/support-release.allizom.org/kitsune; /usr/bin/python26 manage.py cron update_top_contributors
|
||||||
|
|
||||||
# Twice per week.
|
# Twice per week.
|
||||||
#05 01 * * 1,4 cd /data/www/support-release.allizom.org/kitsune; /usr/bin/python26 manage.py cron update_weekly_votes
|
#05 01 * * 1,4 cd /data/www/support-release.allizom.org/kitsune; /usr/bin/python26 manage.py cron update_weekly_votes
|
||||||
|
|
|
@ -251,6 +251,7 @@ INSTALLED_APPS = (
|
||||||
'messages',
|
'messages',
|
||||||
'commonware.response.cookies',
|
'commonware.response.cookies',
|
||||||
'groups',
|
'groups',
|
||||||
|
'karma',
|
||||||
|
|
||||||
# Extra apps for testing.
|
# Extra apps for testing.
|
||||||
'django_nose',
|
'django_nose',
|
||||||
|
@ -690,3 +691,9 @@ REDIS_BACKENDS = {
|
||||||
#'default': 'redis://localhost:6379?socket_timeout=0.5&db=0',
|
#'default': 'redis://localhost:6379?socket_timeout=0.5&db=0',
|
||||||
#'karma': 'redis://localhost:6381?socket_timeout=0.5&db=0',
|
#'karma': 'redis://localhost:6381?socket_timeout=0.5&db=0',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Redis backends used for testing.
|
||||||
|
REDIS_TEST_BACKENDS = {
|
||||||
|
#'default': 'redis://localhost:6383?socket_timeout=0.5&db=0',
|
||||||
|
#'karma': 'redis://localhost:6383?socket_timeout=0.5&db=1',
|
||||||
|
}
|
||||||
|
|
2
vendor
2
vendor
|
@ -1 +1 @@
|
||||||
Subproject commit 461132622f8a5b324bab499089c1d253e915b124
|
Subproject commit 2718b74d3e6483e50dda68ccadcc875ae572afb6
|
Загрузка…
Ссылка в новой задаче