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:
Les Orchard 2011-08-18 20:01:27 -04:00
Родитель e092474a5e
Коммит 402e9738be
9 изменённых файлов: 537 добавлений и 1 удалений

202
badger/feeds.py Normal file
Просмотреть файл

@ -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"),
)

211
badger/validate_jsonp.py Normal file
Просмотреть файл

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

12
requirements/dev.txt Normal file
Просмотреть файл

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

0
requirements/prod.txt Normal file
Просмотреть файл