Initial {rss,atom,json} feeds for awards
- feeds.py package with AwardsRecentFeed, AwardsByUserFeed, AwardsByBadgeFeed - Feeds added to url.py and links added in <head> of respective pages - Simple test for award appearing in feeds - Added requirements/*.txt to describe packages now needed
This commit is contained in:
Родитель
e092474a5e
Коммит
402e9738be
|
@ -0,0 +1,202 @@
|
|||
"""Feeds for badge"""
|
||||
import datetime
|
||||
import hashlib
|
||||
import urllib
|
||||
|
||||
import jingo
|
||||
|
||||
from django.contrib.syndication.views import Feed, FeedDoesNotExist
|
||||
from django.utils.feedgenerator import (SyndicationFeed, Rss201rev2Feed,
|
||||
Atom1Feed, get_tag_uri)
|
||||
import django.utils.simplejson as json
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.conf import settings
|
||||
|
||||
try:
|
||||
from tower import ugettext_lazy as _
|
||||
except ImportError, e:
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
try:
|
||||
from commons.urlresolvers import reverse
|
||||
except ImportError, e:
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from . import validate_jsonp
|
||||
from .models import (Badge, Award, Progress,
|
||||
BadgeAwardNotAllowedException)
|
||||
|
||||
|
||||
MAX_FEED_ITEMS = getattr(settings, 'BADGER_MAX_FEED_ITEMS', 15)
|
||||
|
||||
|
||||
class BaseJSONFeedGenerator(SyndicationFeed):
|
||||
"""JSON feed generator"""
|
||||
# TODO:liberate - Can this class be a generally-useful lib?
|
||||
|
||||
mime_type = 'application/json'
|
||||
|
||||
def _encode_complex(self, obj):
|
||||
if isinstance(obj, datetime.datetime):
|
||||
return obj.isoformat()
|
||||
|
||||
def build_item(self, item):
|
||||
"""Simple base item formatter.
|
||||
Omit some named keys and any keys with false-y values"""
|
||||
omit_keys = ('obj', 'unique_id', )
|
||||
return dict((k,v) for k,v in item.items()
|
||||
if v and k not in omit_keys)
|
||||
|
||||
def build_feed(self):
|
||||
"""Simple base feed formatter.
|
||||
Omit some named keys and any keys with false-y values"""
|
||||
omit_keys = ('obj', 'request', 'id', )
|
||||
feed_data = dict((k,v) for k,v in self.feed.items()
|
||||
if v and k not in omit_keys)
|
||||
feed_data['items'] = [self.build_item(item) for item in self.items]
|
||||
return feed_data
|
||||
|
||||
def write(self, outfile, encoding):
|
||||
request = self.feed['request']
|
||||
|
||||
# Check for a callback param, validate it before use
|
||||
callback = request.GET.get('callback', None)
|
||||
if callback is not None:
|
||||
if not validate_jsonp.is_valid_jsonp_callback_value(callback):
|
||||
callback = None
|
||||
|
||||
# Build the JSON string, wrapping it in a callback param if necessary.
|
||||
json_string = json.dumps(self.build_feed(),
|
||||
default=self._encode_complex)
|
||||
if callback:
|
||||
outfile.write('%s(%s)' % (callback, json_string))
|
||||
else:
|
||||
outfile.write(json_string)
|
||||
|
||||
|
||||
class BaseFeed(Feed):
|
||||
"""Base feed for all of badger, allows switchable generator from URL route
|
||||
and other niceties"""
|
||||
# TODO:liberate - Can this class be a generally-useful lib?
|
||||
|
||||
json_feed_generator = BaseJSONFeedGenerator
|
||||
rss_feed_generator = Rss201rev2Feed
|
||||
atom_feed_generator = Atom1Feed
|
||||
|
||||
def __call__(self, request, *args, **kwargs):
|
||||
self.request = request
|
||||
return super(BaseFeed, self).__call__(request, *args, **kwargs)
|
||||
|
||||
def get_object(self, request, format):
|
||||
self.link = request.build_absolute_uri('/')
|
||||
if format == 'json':
|
||||
self.feed_type = self.json_feed_generator
|
||||
elif format == 'rss':
|
||||
self.feed_type = self.rss_feed_generator
|
||||
else:
|
||||
self.feed_type = self.atom_feed_generator
|
||||
return super(BaseFeed, self).get_object(request)
|
||||
|
||||
def feed_extra_kwargs(self, obj):
|
||||
return {'request': self.request, 'obj': obj, }
|
||||
|
||||
def item_extra_kwargs(self, obj):
|
||||
return {'obj': obj, }
|
||||
|
||||
def item_pubdate(self, obj):
|
||||
return obj.created
|
||||
|
||||
def item_author_link(self, obj):
|
||||
if not obj.creator or not hasattr('get_absolute_url', user):
|
||||
return None
|
||||
else:
|
||||
return self.request.build_absolute_uri(
|
||||
user.get_absolute_url())
|
||||
|
||||
def item_author_name(self, obj):
|
||||
if not obj.creator:
|
||||
return None
|
||||
else:
|
||||
return '%s' % obj.creator
|
||||
|
||||
def item_description(self, obj):
|
||||
return None
|
||||
|
||||
|
||||
class AwardActivityStreamJSONFeedGenerator(BaseJSONFeedGenerator):
|
||||
pass
|
||||
|
||||
|
||||
class AwardActivityStreamAtomFeedGenerator(Atom1Feed):
|
||||
pass
|
||||
|
||||
|
||||
class AwardsFeed(BaseFeed):
|
||||
"""Base class for all feeds listing awards"""
|
||||
title = _('Recently awarded badges')
|
||||
subtitle = None
|
||||
|
||||
json_feed_generator = AwardActivityStreamJSONFeedGenerator
|
||||
atom_feed_generator = AwardActivityStreamAtomFeedGenerator
|
||||
|
||||
def item_title(self, obj):
|
||||
return _('%s awarded to %s') % (obj.badge.title, obj.user)
|
||||
|
||||
def item_author_link(self, obj):
|
||||
if not obj.creator:
|
||||
return None
|
||||
else:
|
||||
return self.request.build_absolute_uri(
|
||||
reverse('badger.views.awards_by_user',
|
||||
args=(obj.creator.username,)))
|
||||
|
||||
def item_link(self, obj):
|
||||
return self.request.build_absolute_uri(
|
||||
reverse('badger.views.award_detail',
|
||||
args=(obj.badge.slug, obj.pk, )))
|
||||
|
||||
|
||||
class AwardsRecentFeed(AwardsFeed):
|
||||
"""Feed of all recent badge awards"""
|
||||
|
||||
def items(self):
|
||||
return (Award.objects
|
||||
.order_by('-created')
|
||||
.all()[:MAX_FEED_ITEMS])
|
||||
|
||||
|
||||
class AwardsByUserFeed(AwardsFeed):
|
||||
"""Feed of recent badge awards for a user"""
|
||||
|
||||
def get_object(self, request, format, username):
|
||||
super(AwardsByUserFeed, self).get_object(request, format)
|
||||
user = get_object_or_404(User, username=username)
|
||||
self.title = _("Badges recently awarded to %s") % user.username
|
||||
self.link = request.build_absolute_uri(
|
||||
reverse('badger.views.awards_by_user', args=(user.username,)))
|
||||
return user
|
||||
|
||||
def items(self, user):
|
||||
return (Award.objects
|
||||
.filter(user=user)
|
||||
.order_by('-created')
|
||||
.all()[:MAX_FEED_ITEMS])
|
||||
|
||||
|
||||
class AwardsByBadgeFeed(AwardsFeed):
|
||||
"""Feed of recent badge awards for a badge"""
|
||||
|
||||
def get_object(self, request, format, slug):
|
||||
super(AwardsByBadgeFeed, self).get_object(request, format)
|
||||
badge = get_object_or_404(Badge, slug=slug)
|
||||
self.title = _('Recent awards of "%s"') % badge.title
|
||||
self.link = request.build_absolute_uri(
|
||||
reverse('badger.views.awards_by_badge', args=(badge.slug,)))
|
||||
return badge
|
||||
|
||||
def items(self, badge):
|
||||
return (Award.objects
|
||||
.filter(badge=badge).order_by('-created')
|
||||
.all()[:MAX_FEED_ITEMS])
|
|
@ -2,6 +2,12 @@
|
|||
|
||||
{% block pageid %}badge_awards_by_badge{% endblock %}
|
||||
|
||||
{% block extrahead %}
|
||||
<link rel="alternate" type="application/atom+xml"
|
||||
title="{{ _('Recent awards') }}"
|
||||
href="{{ url('badger.feeds.awards_by_badge', 'atom', badge.slug) }}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h2>Badge detail for {{badge.title}}</h2>
|
||||
|
|
|
@ -2,6 +2,12 @@
|
|||
|
||||
{% block pageid %}badge_awards_by_user{% endblock %}
|
||||
|
||||
{% block extrahead %}
|
||||
<link rel="alternate" type="application/atom+xml"
|
||||
title="{{ _('Recent awards') }}"
|
||||
href="{{ url('badger.feeds.awards_by_user', 'atom', user.username) }}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Awards for {{user}}</h2>
|
||||
|
||||
|
|
|
@ -2,6 +2,12 @@
|
|||
|
||||
{% block pageid %}badge_detail{% endblock %}
|
||||
|
||||
{% block extrahead %}
|
||||
<link rel="alternate" type="application/atom+xml"
|
||||
title="{{ _('Recent awards') }}"
|
||||
href="{{ url('badger.feeds.awards_by_badge', 'atom', badge.slug) }}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h2>Badge detail</h2>
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
import logging
|
||||
import feedparser
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.test.client import Client
|
||||
|
||||
from commons import LocalizingClient
|
||||
|
||||
from pyquery import PyQuery as pq
|
||||
|
||||
from nose.tools import assert_equal, with_setup, assert_false, eq_, ok_
|
||||
from nose.plugins.attrib import attr
|
||||
|
||||
from django.template.defaultfilters import slugify
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
try:
|
||||
from commons.urlresolvers import reverse
|
||||
except ImportError, e:
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from . import BadgerTestCase
|
||||
|
||||
from badger.models import (Badge, Award, Progress,
|
||||
BadgeAwardNotAllowedException)
|
||||
from badger.utils import get_badge, award_badge
|
||||
|
||||
|
||||
class BadgerFeedsTest(BadgerTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.testuser = self._get_user()
|
||||
self.client = LocalizingClient()
|
||||
Award.objects.all().delete()
|
||||
|
||||
def tearDown(self):
|
||||
Award.objects.all().delete()
|
||||
Badge.objects.all().delete()
|
||||
|
||||
def test_award_feeds(self):
|
||||
"""Can view award detail"""
|
||||
user = self._get_user()
|
||||
user2 = self._get_user(username='tester2')
|
||||
|
||||
b1, created = Badge.objects.get_or_create(creator=user, title="Code Badge #1")
|
||||
award = b1.award_to(user2)
|
||||
|
||||
# The award should show up in each of these feeds.
|
||||
feed_urls = (
|
||||
reverse('badger.feeds.awards_recent',
|
||||
args=('atom', )),
|
||||
reverse('badger.feeds.awards_by_badge',
|
||||
args=('atom', b1.slug, )),
|
||||
reverse('badger.feeds.awards_by_user',
|
||||
args=('atom', user2.username,)),
|
||||
)
|
||||
|
||||
# Check each of the feeds
|
||||
for feed_url in feed_urls:
|
||||
r = self.client.get(feed_url, follow=True)
|
||||
|
||||
# The feed should be parsed without issues by feedparser
|
||||
feed = feedparser.parse(r.content)
|
||||
eq_(0, feed.bozo)
|
||||
|
||||
# Look through entries for the badge title
|
||||
found_it = False
|
||||
for entry in feed.entries:
|
||||
if b1.title in entry.title and user2.username in entry.title:
|
||||
found_it = True
|
||||
|
||||
ok_(found_it)
|
||||
|
||||
def _get_user(self, username="tester", email="tester@example.com",
|
||||
password="trustno1"):
|
||||
(user, created) = User.objects.get_or_create(username=username,
|
||||
defaults=dict(email=email))
|
||||
if created:
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
return user
|
|
@ -4,6 +4,8 @@ from django.conf import settings
|
|||
from django.views.generic.list_detail import object_list
|
||||
from django.views.generic.simple import direct_to_template
|
||||
|
||||
from .feeds import AwardsRecentFeed, AwardsByUserFeed, AwardsByBadgeFeed
|
||||
|
||||
|
||||
urlpatterns = patterns('badger.views',
|
||||
url(r'^$', 'index', name='badger.index'),
|
||||
|
@ -16,5 +18,12 @@ urlpatterns = patterns('badger.views',
|
|||
url(r'^detail/(?P<slug>[^/]+)/awards/?$', 'awards_by_badge',
|
||||
name='badger.awards_by_badge'),
|
||||
url(r'^users/(?P<username>[^/]+)/awards/?$', 'awards_by_user',
|
||||
name='badger_awards_by_user'),
|
||||
name='badger.awards_by_user'),
|
||||
|
||||
url(r'^feeds/(?P<format>[^/]+)/awards/?$',
|
||||
AwardsRecentFeed(), name="badger.feeds.awards_recent"),
|
||||
url(r'^feeds/(?P<format>[^/]+)/detail/(?P<slug>[^/]+)/awards/?$',
|
||||
AwardsByBadgeFeed(), name="badger.feeds.awards_by_badge"),
|
||||
url(r'^feeds/(?P<format>[^/]+)/users/(?P<username>[^/]+)/awards/?$',
|
||||
AwardsByUserFeed(), name="badger.feeds.awards_by_user"),
|
||||
)
|
||||
|
|
|
@ -0,0 +1,211 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# see also: http://github.com/tav/scripts/raw/master/validate_jsonp.py
|
||||
# Placed into the Public Domain by tav <tav@espians.com>
|
||||
|
||||
"""Validate Javascript Identifiers for use as JSON-P callback parameters."""
|
||||
|
||||
import re
|
||||
|
||||
from unicodedata import category
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# javascript identifier unicode categories and "exceptional" chars
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
valid_jsid_categories_start = frozenset([
|
||||
'Lu', 'Ll', 'Lt', 'Lm', 'Lo', 'Nl'
|
||||
])
|
||||
|
||||
valid_jsid_categories = frozenset([
|
||||
'Lu', 'Ll', 'Lt', 'Lm', 'Lo', 'Nl', 'Mn', 'Mc', 'Nd', 'Pc'
|
||||
])
|
||||
|
||||
valid_jsid_chars = ('$', '_')
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# regex to find array[index] patterns
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
array_index_regex = re.compile(r'\[[0-9]+\]$')
|
||||
|
||||
has_valid_array_index = array_index_regex.search
|
||||
replace_array_index = array_index_regex.sub
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# javascript reserved words -- including keywords and null/boolean literals
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
is_reserved_js_word = frozenset([
|
||||
|
||||
'abstract', 'boolean', 'break', 'byte', 'case', 'catch', 'char', 'class',
|
||||
'const', 'continue', 'debugger', 'default', 'delete', 'do', 'double',
|
||||
'else', 'enum', 'export', 'extends', 'false', 'final', 'finally', 'float',
|
||||
'for', 'function', 'goto', 'if', 'implements', 'import', 'in', 'instanceof',
|
||||
'int', 'interface', 'long', 'native', 'new', 'null', 'package', 'private',
|
||||
'protected', 'public', 'return', 'short', 'static', 'super', 'switch',
|
||||
'synchronized', 'this', 'throw', 'throws', 'transient', 'true', 'try',
|
||||
'typeof', 'var', 'void', 'volatile', 'while', 'with',
|
||||
|
||||
# potentially reserved in a future version of the ES5 standard
|
||||
# 'let', 'yield'
|
||||
|
||||
]).__contains__
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# the core validation functions
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
def is_valid_javascript_identifier(identifier, escape=r'\u', ucd_cat=category):
|
||||
"""Return whether the given ``id`` is a valid Javascript identifier."""
|
||||
|
||||
if not identifier:
|
||||
return False
|
||||
|
||||
if not isinstance(identifier, unicode):
|
||||
try:
|
||||
identifier = unicode(identifier, 'utf-8')
|
||||
except UnicodeDecodeError:
|
||||
return False
|
||||
|
||||
if escape in identifier:
|
||||
|
||||
new = []; add_char = new.append
|
||||
split_id = identifier.split(escape)
|
||||
add_char(split_id.pop(0))
|
||||
|
||||
for segment in split_id:
|
||||
if len(segment) < 4:
|
||||
return False
|
||||
try:
|
||||
add_char(unichr(int('0x' + segment[:4], 16)))
|
||||
except Exception:
|
||||
return False
|
||||
add_char(segment[4:])
|
||||
|
||||
identifier = u''.join(new)
|
||||
|
||||
if is_reserved_js_word(identifier):
|
||||
return False
|
||||
|
||||
first_char = identifier[0]
|
||||
|
||||
if not ((first_char in valid_jsid_chars) or
|
||||
(ucd_cat(first_char) in valid_jsid_categories_start)):
|
||||
return False
|
||||
|
||||
for char in identifier[1:]:
|
||||
if not ((char in valid_jsid_chars) or
|
||||
(ucd_cat(char) in valid_jsid_categories)):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def is_valid_jsonp_callback_value(value):
|
||||
"""Return whether the given ``value`` can be used as a JSON-P callback."""
|
||||
|
||||
for identifier in value.split(u'.'):
|
||||
while '[' in identifier:
|
||||
if not has_valid_array_index(identifier):
|
||||
return False
|
||||
identifier = replace_array_index(u'', identifier)
|
||||
if not is_valid_javascript_identifier(identifier):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# test
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
def test():
|
||||
"""
|
||||
The function ``is_valid_javascript_identifier`` validates a given identifier
|
||||
according to the latest draft of the ECMAScript 5 Specification:
|
||||
|
||||
>>> is_valid_javascript_identifier('hello')
|
||||
True
|
||||
|
||||
>>> is_valid_javascript_identifier('alert()')
|
||||
False
|
||||
|
||||
>>> is_valid_javascript_identifier('a-b')
|
||||
False
|
||||
|
||||
>>> is_valid_javascript_identifier('23foo')
|
||||
False
|
||||
|
||||
>>> is_valid_javascript_identifier('foo23')
|
||||
True
|
||||
|
||||
>>> is_valid_javascript_identifier('$210')
|
||||
True
|
||||
|
||||
>>> is_valid_javascript_identifier(u'Stra\u00dfe')
|
||||
True
|
||||
|
||||
>>> is_valid_javascript_identifier(r'\u0062') # u'b'
|
||||
True
|
||||
|
||||
>>> is_valid_javascript_identifier(r'\u62')
|
||||
False
|
||||
|
||||
>>> is_valid_javascript_identifier(r'\u0020')
|
||||
False
|
||||
|
||||
>>> is_valid_javascript_identifier('_bar')
|
||||
True
|
||||
|
||||
>>> is_valid_javascript_identifier('some_var')
|
||||
True
|
||||
|
||||
>>> is_valid_javascript_identifier('$')
|
||||
True
|
||||
|
||||
But ``is_valid_jsonp_callback_value`` is the function you want to use for
|
||||
validating JSON-P callback parameter values:
|
||||
|
||||
>>> is_valid_jsonp_callback_value('somevar')
|
||||
True
|
||||
|
||||
>>> is_valid_jsonp_callback_value('function')
|
||||
False
|
||||
|
||||
>>> is_valid_jsonp_callback_value(' somevar')
|
||||
False
|
||||
|
||||
It supports the possibility of '.' being present in the callback name, e.g.
|
||||
|
||||
>>> is_valid_jsonp_callback_value('$.ajaxHandler')
|
||||
True
|
||||
|
||||
>>> is_valid_jsonp_callback_value('$.23')
|
||||
False
|
||||
|
||||
As well as the pattern of providing an array index lookup, e.g.
|
||||
|
||||
>>> is_valid_jsonp_callback_value('array_of_functions[42]')
|
||||
True
|
||||
|
||||
>>> is_valid_jsonp_callback_value('array_of_functions[42][1]')
|
||||
True
|
||||
|
||||
>>> is_valid_jsonp_callback_value('$.ajaxHandler[42][1].foo')
|
||||
True
|
||||
|
||||
>>> is_valid_jsonp_callback_value('array_of_functions[42]foo[1]')
|
||||
False
|
||||
|
||||
>>> is_valid_jsonp_callback_value('array_of_functions[]')
|
||||
False
|
||||
|
||||
>>> is_valid_jsonp_callback_value('array_of_functions["key"]')
|
||||
False
|
||||
|
||||
Enjoy!
|
||||
|
||||
"""
|
||||
|
||||
if __name__ == '__main__':
|
||||
import doctest
|
||||
doctest.testmod()
|
|
@ -0,0 +1,12 @@
|
|||
# This file pulls in everything a developer needs. If it's a basic package
|
||||
# needed to run the site, it belongs in requirements/prod.txt. If it's a
|
||||
# package for developers (testing, docs, etc.), it goes in this file.
|
||||
|
||||
-r prod.txt
|
||||
|
||||
# Testing
|
||||
nose==1.0.0
|
||||
-e git://github.com/jbalogh/django-nose.git#egg=django_nose
|
||||
-e git://github.com/jbalogh/test-utils.git#egg=test-utils
|
||||
|
||||
feedparser
|
Загрузка…
Ссылка в новой задаче