diff --git a/apps/badges/feeds.py b/apps/badges/feeds.py new file mode 100644 index 0000000..48c9eb2 --- /dev/null +++ b/apps/badges/feeds.py @@ -0,0 +1,187 @@ +""" +Feeds for badges +""" +#from django.contrib.syndication.feeds import Feed +from django.contrib.syndication.views import Feed, FeedDoesNotExist +from django.utils.feedgenerator import Atom1Feed, get_tag_uri +from django.shortcuts import get_object_or_404 + +from django.utils.translation import ugettext as _ + +from django.contrib.auth.models import User +from django.contrib.sites.models import Site +from django.conf import settings + +from badger.apps.badges.models import Badge, BadgeNomination +from badger.apps.badges.models import BadgeAward, BadgeAwardee + +from avatar.templatetags.avatar_tags import avatar_url +from badges.templatetags.badge_tags import badge_url + + +class ActivityStreamFeedGenerator(Atom1Feed): + """Tweaks to Atom feed to include Activity Stream data""" + + def root_attributes(self): + attrs = super(ActivityStreamFeedGenerator, self).root_attributes() + attrs['xmlns:activity'] = 'http://activitystrea.ms/spec/1.0/' + attrs['xmlns:media'] = 'http://purl.org/syndication/atommedia' + return attrs + + def add_item_elements(self, handler, item): + """Inject Activity Stream elements into an item""" + + handler.addQuickElement('published', item['pubdate'].isoformat()) + item['pubdate'] = None + + # Author information. + if item['author_name'] is not None: + handler.startElement(u"author", {}) + handler.addQuickElement(u"activity:object-type", + 'http://activitystrea.ms/schema/1.0/person') + handler.addQuickElement(u"name", item['author_name']) + if item['author_email'] is not None: + handler.addQuickElement(u"email", item['author_email']) + if item['author_link'] is not None: + handler.addQuickElement(u"uri", item['author_link']) + handler.addQuickElement(u"id", + get_tag_uri(item['author_link'], item['pubdate'])) + handler.addQuickElement(u"link", u"", { + 'type': 'text/html', 'rel':'alternate', + 'href': item['author_link'] + }) + handler.addQuickElement(u"link", u"", { + 'type': 'image/jpeg', 'rel':'photo', + 'media:width': '64', 'media:height': '64', + 'href': 'http://%s%s' % ( + Site.objects.get_current().domain, + avatar_url(item['obj'].claimed_by, 64) + ), + }) + avatar_href = avatar_url(item['obj'].claimed_by, 64) + if avatar_href.startswith('/'): + avatar_href = 'http://%s%s' % ( + Site.objects.get_current().domain, avatar_href + ) + handler.addQuickElement(u"link", u"", { + 'type': 'image/jpeg', 'rel':'preview', + 'media:width': '64', 'media:height': '64', + 'href': avatar_href, + }) + handler.endElement(u"author") + item['author_name'] = None + + handler.addQuickElement('activity:verb', item['activity']['verb']) + + a_object = item['activity']['object'] + handler.startElement(u"activity:object", {}) + handler.addQuickElement(u"activity:object-type", a_object['object-type']) + handler.addQuickElement(u"title", a_object['name']) + handler.addQuickElement(u"id", get_tag_uri(a_object['link'], + item['pubdate'])) + handler.addQuickElement(u"link", '', { + 'href': a_object['link'], 'rel':'alternate', 'type':'text/html' + }) + handler.addQuickElement(u"link", u"", { + 'type': 'image/jpeg', 'rel':'preview', + 'media:width': a_object['preview']['width'], + 'media:height': a_object['preview']['height'], + 'href': a_object['preview']['href'], + }) + handler.endElement(u"activity:object") + + super(ActivityStreamFeedGenerator, self).add_item_elements(handler, item) + + +class AwardActivityStreamFeed(Feed): + """Tweaks to standard feed to include Activity Stream info + for lists of badge awards""" + feed_type = ActivityStreamFeedGenerator + + def item_author_name(self, item): + return '%s' % item.claimed_by + + def item_author_link(self, item): + current_site = Site.objects.get(id=settings.SITE_ID) + return 'http://%s%s' % (Site.objects.get_current().domain, + item.claimed_by.get_absolute_url()) + + def item_pubdate(self, item): + return item.updated_at + + def item_title(self, item): + return '%s claimed the badge "%s"' % (item.claimed_by, item.badge) + + def item_description(self, item): + # TODO: Stick this in a template? + avatar_img = avatar_url(item.claimed_by, 64) + if avatar_img.startswith('/'): + avatar_img = 'http://%s%s' % ( + Site.objects.get_current().domain, avatar_img) + badge_img = badge_url(item.badge, 64) + if badge_img.startswith('/'): + badge_img = 'http://%s%s' % ( + Site.objects.get_current().domain, badge_img) + return """ + %(claimed_by)s + claimed the badge + "%(badge_title)s" + """ % { + 'avatar_img': avatar_img, + 'badge_img': badge_img, + 'claimed_by': item.claimed_by, + 'claimed_by_url': item.claimed_by.get_absolute_url(), + 'badge_title': item.badge.title, + 'badge_url': item.badge.get_absolute_url(), + 'award_url': item.get_absolute_url(), + } + + def item_extra_kwargs(self, obj): + return { + 'obj': obj, + 'activity': { + 'verb': 'http://badger.decafbad.com/activity/1.0/verbs/claim', + 'object': { + 'object-type': + 'http://badger.decafbad.com/activity/1.0/objects/badge', + 'name': obj.badge.title, + 'link': 'http://%s%s' % ( + Site.objects.get_current().domain, + obj.badge.get_absolute_url() + ), + 'preview': { + 'width': '64', 'height': '64', + 'href': 'http://%s%s' % ( + Site.objects.get_current().domain, + badge_url(obj.badge, 64) + ), + }, + }, + }, + } + + +class RecentlyClaimedAwardsFeed(AwardActivityStreamFeed): + """Feed of recently claimed badge awards""" + + title = _('Recently claimed badges') + subtitle = _('Badges recently claimed by people') + link = '/' + + def items(self): + return BadgeAward.objects.filter(claimed=True).exclude(hidden=True)\ + .order_by('-updated_at')[:15] + +class AwardsClaimedForProfileFeed(AwardActivityStreamFeed): + + title = _('Recently claimed badges') + link = '/' + + def get_object(self, request, username): + return get_object_or_404(User, username=username) + + def items(self, user): + self.title = "%s's recently claimed badges" % user.username + return BadgeAward.objects.filter(claimed_by=user, claimed=True)\ + .exclude(hidden=True).order_by('-updated_at')[:15] + diff --git a/apps/badges/media/badges/img/feed-icon-14x14.png b/apps/badges/media/badges/img/feed-icon-14x14.png new file mode 100755 index 0000000..b3c949d Binary files /dev/null and b/apps/badges/media/badges/img/feed-icon-14x14.png differ diff --git a/apps/badges/templates/notification/badge_awarded/full.txt b/apps/badges/templates/notification/badge_awarded/full.txt index 76d28ec..5ae1875 100644 --- a/apps/badges/templates/notification/badge_awarded/full.txt +++ b/apps/badges/templates/notification/badge_awarded/full.txt @@ -1,3 +1,3 @@ -{% load i18n %}{% load account_tags %}{% load badge_tags %}{% awardee_display award.awardee as awardee_display %}{% user_display award.nomination.approved_by as approved_by_display %}{% user_display award.nomination.nominator as nominator_display %}{% blocktrans with award.get_absolute_url as award_url and award.badge.title as badge_title %}{{ approved_by_display }} has approved {{ nominator_display }}'s nomination of {{ awardee_display }} for the badge {{ badge_title }} +{% load i18n %}{% load account_tags %}{% load badge_tags %}{% awardee_display award.awardee as awardee_display %}{% user_display award.nomination.approved_by as approved_by_display %}{% user_display award.nomination.nominator as nominator_display %}{% blocktrans with award.get_absolute_url as award_url and award.badge.title as badge_title %}{{ awardee_display }} has been awarded the badge {{ badge_title }} http://{{ current_site }}{{ award_url }}{% endblocktrans %} diff --git a/apps/badges/templates/notification/badge_awarded/notice.html b/apps/badges/templates/notification/badge_awarded/notice.html index d078e72..dae620c 100644 --- a/apps/badges/templates/notification/badge_awarded/notice.html +++ b/apps/badges/templates/notification/badge_awarded/notice.html @@ -4,4 +4,4 @@ {% user_display award.nomination.nominator as nominator_display %} {% url profile_detail username=award.nomination.approved_by.username as approved_by_url %} {% url profile_detail username=award.nomination.nominator.username as nominator_url %} -{% blocktrans with award.awardee.get_absolute_url as awardee_url and award.awardee.display as awardee_display and award.get_absolute_url as award_url and award.get_absolute_url as badge_url and award.badge.title as badge_title and award.reason_why as reason_why %}{{ approved_by_display }} has approved {{ nominator_display }}'s nomination of {{ awardee_display }} for the badge {{ badge_title }}{% endblocktrans %} +{% blocktrans with award.awardee.get_absolute_url as awardee_url and award.awardee.display as awardee_display and award.get_absolute_url as award_url and award.badge.get_absolute_url as badge_url and award.badge.title as badge_title and award.reason_why as reason_why %}{{ awardee_display }} has been awarded the badge {{ badge_title }}.{% endblocktrans %} diff --git a/apps/badges/templatetags/badge_tags.py b/apps/badges/templatetags/badge_tags.py index f285ccc..a3cceb1 100644 --- a/apps/badges/templatetags/badge_tags.py +++ b/apps/badges/templatetags/badge_tags.py @@ -6,10 +6,10 @@ from django.utils.hashcompat import md5_constructor from django.contrib.auth.models import User -from badger.apps.badges.models import Badge, BadgeNomination -from badger.apps.badges.models import BadgeAward, BadgeAwardee +from badges.models import Badge, BadgeNomination +from badges.models import BadgeAward, BadgeAwardee -from badger.apps.badges import BADGE_DEFAULT_URL +from badges import BADGE_DEFAULT_URL register = template.Library() diff --git a/apps/badges/tests/test_atom_feeds.py b/apps/badges/tests/test_atom_feeds.py new file mode 100644 index 0000000..8c81dea --- /dev/null +++ b/apps/badges/tests/test_atom_feeds.py @@ -0,0 +1,148 @@ +""" """ +import logging +import re +import urlparse +import StringIO +import time + +from lxml import etree +from pyquery import PyQuery + +from xml.etree import ElementTree +from activitystreams.atom import make_activities_from_feed +from activitystreams.json import make_activities_from_stream_dict +from django.utils import simplejson as json + +from django.http import HttpRequest +from django.test import TestCase +from django.test.client import Client + +from django.contrib.auth.models import User + +from pinax.apps.profiles.models import Profile +from pinax.apps.account.models import Account + +from badger.apps.badges.models import Badge, BadgeNomination, BadgeAward + +from nose.tools import assert_equal, with_setup, assert_false, eq_, ok_ +from nose.plugins.attrib import attr + +from django.contrib.auth.models import User +from pinax.apps.profiles.models import Profile +from pinax.apps.account.models import Account +from badger.apps.badges.models import Badge, BadgeNomination +from badger.apps.badges.models import BadgeAward, BadgeAwardee +from mailer.models import Message, MessageLog +from notification.models import NoticeType, Notice + +class TestAtomFeeds(TestCase): + + def setUp(self): + self.log = logging.getLogger('nose.badger') + self.browser = Client() + + for user in User.objects.all(): + user.delete() + + self.users = {} + for name in ( 'user1', 'user2', 'user3'): + self.users[name] = self.get_user(name) + + def tearDown(self): + pass + + def test_recent_awards(self): + """Ensure the recent awards feed parses as an Activity Stream""" + badge_awards = ( + ( 'badge1', 'user1', 'user2', 'user3' ), + ( 'badge2', 'user1', 'user3', 'user2' ), + ( 'badge3', 'user3', 'user1', 'user2' ), + ( 'badge4', 'user1', 'user1', 'user1' ), + ) + badges, awards = self.build_awards(badge_awards) + self.verify_activity_stream(badge_awards, + '/badges/feeds/atom/recentawards/') + + def test_profile_awards(self): + """Ensure the award feed for a single profile parses as an Activity Stream""" + badge_awards = ( + ( 'badge1', 'user1', 'user2', 'user3' ), + ( 'badge2', 'user2', 'user1', 'user3' ), + ( 'badge3', 'user1', 'user2', 'user3' ), + ( 'badge4', 'user2', 'user1', 'user3' ), + ) + badges, awards = self.build_awards(badge_awards) + self.verify_activity_stream(badge_awards, + '/badges/feeds/atom/profiles/user3/awards/') + + ####################################################################### + + def get_user(self, username, password=None, email=None): + """Get a user for the given username, creating it if necessary.""" + if password is None: password = '%s_password' % username + if email is None: email = '%s@example.com' % username + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + user = User.objects.create_user(username, email, password) + ok_(user is not None, "user should exist") + return user + + def build_awards(self, badge_awards): + badges, awards = {}, {} + + for details in badge_awards: + badge_name, creator_name, nominator_name, nominee_name = details + + creator = self.users[creator_name] + nominator = self.users[nominator_name] + nominee_user = self.users[nominee_name] + nominee, c = BadgeAwardee.objects.get_or_create(user=nominee_user) + + badge = Badge(title=badge_name, creator=creator, + description='%s description' % badge_name) + badge.save() + badges[badge_name] = badge + + nomination = badge.nominate(nominator, nominee, + '%s nomination reason' % badge_name) + award = nomination.approve(creator, + '%s approval reason' % badge_name) + award.claim(nominee_user) + + awards[details] = award + + time.sleep(1) + + return badges, awards + + def verify_activity_stream(self, badge_awards, path): + resp = self.browser.get(path) + + et = ElementTree.parse(StringIO.StringIO(resp.content)) + activities = make_activities_from_feed(et) + + # Ensure feed activity count matches award count + eq_(len(badge_awards), len(activities)) + + for details in badge_awards: + badge_name, creator_name, nominator_name, nominee_name = details + act = activities.pop() + + # Check the actor for this activity + eq_(nominee_name, act.actor.name) + eq_('http://activitystrea.ms/schema/1.0/person', + act.actor.object_type) + eq_('http://example.com/profiles/profile/%s/' % nominee_name, + act.actor.url) + + # Check the verb for this activity + eq_('http://badger.decafbad.com/activity/1.0/verbs/claim', act.verb) + + # Check the object for this activity + eq_(badge_name, act.object.name) + eq_('http://badger.decafbad.com/activity/1.0/objects/badge', + act.object.object_type) + eq_('http://example.com/badges/badge/%s' % (badge_name), + act.object.url) + diff --git a/apps/badges/tests/test_models.py b/apps/badges/tests/test_models.py deleted file mode 100644 index c7e898f..0000000 --- a/apps/badges/tests/test_models.py +++ /dev/null @@ -1,23 +0,0 @@ -""" """ -from django.http import HttpRequest -from django.test import TestCase - -from django.contrib.auth.models import User - -from pinax.apps.profiles.models import Profile -from pinax.apps.account.models import Account - -from badger.apps.badges.models import Badge, BadgeNomination, BadgeAward - -from nose.tools import assert_equal, with_setup, assert_false, eq_, ok_ - -class TestBadges(TestCase): - - def setUp(self): - self.users = {} - for user_name in ( 'user1', 'user2', 'user3' ): - self.users[user_name] = User.objects.create(username=user_name) - - def tearDown(self): - for name, user in self.users.items(): - user.delete() diff --git a/apps/badges/urls.py b/apps/badges/urls.py index f59876d..cbf2add 100644 --- a/apps/badges/urls.py +++ b/apps/badges/urls.py @@ -1,4 +1,5 @@ from django.conf.urls.defaults import * +from badges.feeds import RecentlyClaimedAwardsFeed, AwardsClaimedForProfileFeed urlpatterns = patterns("badger.apps.badges.views", url(r"^$", "index", name="badge_index"), @@ -11,4 +12,9 @@ url(r"^badge/(.*)/awards/(.*)/(.*)/showhide$", "award_show_hide_single", name="badge_award_show_hide_single"), url(r"^badge/(.*)/edit$", "edit", name="badge_edit"), url(r"^badge/(.*)$", "badge_details", name="badge_details"), + + url(r'feeds/atom/recentawards/', RecentlyClaimedAwardsFeed(), + name="badge_feed_recentawards"), + url(r'feeds/atom/profiles/(.*)/awards/', AwardsClaimedForProfileFeed(), + name="badge_feed_profileawards"), ) diff --git a/local_settings.py-dist b/local_settings.py-dist index 442e629..003e7f0 100644 --- a/local_settings.py-dist +++ b/local_settings.py-dist @@ -4,7 +4,14 @@ from os.path import abspath, dirname, join DEBUG = True DEV = True +SERVE_MEDIA = DEBUG +EMAIL_DEBUG = DEBUG CACHE_BACKEND = 'file://%s/cache' % ( abspath(dirname(__file__)) ) MAILER_EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +INSTALLED_APPS += [ + "django_nose", +] +SOUTH_TESTS_MIGRATE = False +TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' diff --git a/requirements/base.txt b/requirements/base.txt index 28e4a1c..57bdc26 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -70,4 +70,4 @@ django-timezones==0.2.dev1 idios==0.1.dev6 #Pinax --e git+http://github.com/lmorchard/pinax.git#egg=pinax +-e git://github.com/lmorchard/pinax.git#egg=Pinax diff --git a/requirements/dev.txt b/requirements/dev.txt index fc34dea..b4727f1 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -6,10 +6,11 @@ coverage nose -e git+git://github.com/cmheisel/nose-xcover.git#egg=nosexcover --e git+http://github.com/jbalogh/django-nose.git#egg=django-nose +-e git://github.com/jbalogh/django-nose.git#egg=django_nose -e git+http://github.com/jbalogh/check.git#egg=check lxml pyquery freshen mock + diff --git a/requirements/pinax-dev.txt b/requirements/pinax-dev.txt index d86a8cc..4283ff9 100644 --- a/requirements/pinax-dev.txt +++ b/requirements/pinax-dev.txt @@ -9,7 +9,7 @@ python-memcached coverage nose -e git+git://github.com/cmheisel/nose-xcover.git#egg=nosexcover --e git+http://github.com/jbalogh/django-nose.git#egg=django-nose +-e git://github.com/jbalogh/django-nose.git#egg=django_nose -e git+http://github.com/jbalogh/check.git#egg=check lxml diff --git a/settings.py b/settings.py index 91f3c6d..82103c2 100644 --- a/settings.py +++ b/settings.py @@ -1,17 +1,24 @@ # -*- coding: utf-8 -*- # Django settings for social pinax project. +import os +import sys +from os.path import abspath, dirname, join +from site import addsitedir import os.path import posixpath import pinax +sys.path.insert(0, abspath(join(dirname(__file__), "libs"))) +sys.path.insert(0, abspath(join(dirname(__file__), "vendor"))) + PINAX_ROOT = os.path.abspath(os.path.dirname(pinax.__file__)) PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__)) # tells Pinax to use the default theme PINAX_THEME = "default" -DEBUG = True +DEBUG = False TEMPLATE_DEBUG = DEBUG # tells Pinax to serve media through the staticfiles app. @@ -282,13 +289,6 @@ def write(*args, **kwargs): "INTERCEPT_REDIRECTS": False, } -if DEBUG: - INSTALLED_APPS += [ - "django_nose", - ] - SOUTH_TESTS_MIGRATE = False - TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' - # local_settings.py can be used to override environment-specific settings # like database and email that differ between development and production. try: diff --git a/templates/homepage.html b/templates/homepage.html index f5748e1..fc6e84c 100644 --- a/templates/homepage.html +++ b/templates/homepage.html @@ -8,6 +8,10 @@ {% load humanize i18n %} {% load timezone_filters %} +{% block extra_head %} + +{% endblock %} + {% block head_title %}{% trans "Welcome" %}{% endblock %} {% block body_class %}home{% endblock %} @@ -36,7 +40,7 @@ {% recent_badge_awards as recent_awards %}
-

Recent awards

+

Recent awards