From a7cfcfd7849b1aa8fe0a206aac380976aaa1500a Mon Sep 17 00:00:00 2001 From: groovecoder Date: Wed, 23 Feb 2011 15:37:37 -0600 Subject: [PATCH] bug 634402 bug 634263 move mdn apps into kuma; functional but mis-styled --- apps/actioncounters/README.md | 10 + apps/actioncounters/__init__.py | 0 apps/actioncounters/admin.py | 8 + apps/actioncounters/fields.py | 130 +++++ apps/actioncounters/models.py | 113 ++++ apps/actioncounters/tests.py | 197 +++++++ apps/actioncounters/utils.py | 66 +++ apps/actioncounters/views.py | 0 apps/contentflagging/README.md | 0 apps/contentflagging/__init__.py | 0 apps/contentflagging/admin.py | 13 + apps/contentflagging/forms.py | 41 ++ apps/contentflagging/models.py | 125 +++++ apps/contentflagging/tests.py | 84 +++ apps/contentflagging/utils.py | 67 +++ apps/dekicompat/Django_Notes.txt | 12 + apps/dekicompat/NOTES.txxt | 38 ++ apps/dekicompat/__init__.py | 0 apps/dekicompat/auth.py.bak | 68 +++ apps/dekicompat/backends.py | 185 +++++++ apps/dekicompat/middleware.py | 47 ++ apps/dekicompat/tests.py | 113 ++++ apps/dekicompat/views.py | 17 + apps/demos/__init__.py | 296 +++++++++++ apps/demos/admin.py | 10 + apps/demos/feeds.py | 222 ++++++++ apps/demos/forms.py | 153 ++++++ apps/demos/helpers.py | 380 +++++++++++++ apps/demos/models.py | 500 ++++++++++++++++++ apps/demos/templates/demos/base.html | 76 +++ apps/demos/templates/demos/base_popup.html | 25 + apps/demos/templates/demos/delete.html | 58 ++ .../demos/templates/demos/delete_comment.html | 62 +++ apps/demos/templates/demos/detail.html | 362 +++++++++++++ .../demos/elements/comments_tree.html | 65 +++ .../templates/demos/elements/gravatar.html | 2 + .../demos/elements/profile_link.html | 2 + .../templates/demos/elements/search_form.html | 8 + .../demos/elements/submission_creator.html | 2 + .../demos/elements/submission_listing.html | 69 +++ .../demos/elements/submission_thumb.html | 22 + .../templates/demos/elements/tags_list.html | 8 + .../demos/feed_item_description.html | 17 + apps/demos/templates/demos/flag.html | 55 ++ apps/demos/templates/demos/home.html | 142 +++++ apps/demos/templates/demos/launch.html | 130 +++++ apps/demos/templates/demos/listing_all.html | 50 ++ .../demos/templates/demos/listing_search.html | 57 ++ apps/demos/templates/demos/listing_tag.html | 66 +++ .../demos/templates/demos/profile_detail.html | 68 +++ apps/demos/templates/demos/submit.html | 260 +++++++++ apps/demos/templates/demos/submit_noauth.html | 78 +++ apps/demos/templates/demos/terms.html | 174 ++++++ apps/demos/tests.py | 264 +++++++++ apps/demos/urls.py | 54 ++ apps/demos/views.py | 268 ++++++++++ apps/devmo/__init__.py | 31 ++ apps/devmo/context_processors.py | 20 + apps/devmo/fixtures/initial_data.json | 30 ++ apps/devmo/helpers.py | 145 +++++ apps/devmo/middleware.py | 60 +++ apps/devmo/models.py | 46 ++ apps/devmo/tests.py | 105 ++++ apps/devmo/urlresolvers.py | 114 ++++ apps/docs/__init__.py | 0 apps/docs/cron.py | 94 ++++ apps/docs/models.py | 0 apps/docs/templates/docs/docs.html | 143 +++++ apps/docs/templates/docs/glossary.html | 27 + apps/docs/urls.py | 6 + apps/docs/views.py | 63 +++ apps/feeder/__init__.py | 4 + apps/feeder/admin.py | 21 + apps/feeder/management/__init__.py | 0 apps/feeder/management/commands/__init__.py | 0 .../management/commands/update_feeds.py | 230 ++++++++ apps/feeder/models.py | 120 +++++ apps/landing/__init__.py | 0 apps/landing/helpers.py | 45 ++ apps/landing/models.py | 0 apps/landing/templates/landing/addons.html | 157 ++++++ .../templates/landing/discussions.html | 15 + apps/landing/templates/landing/home.html | 224 ++++++++ apps/landing/templates/landing/mobile.html | 199 +++++++ apps/landing/templates/landing/mozilla.html | 134 +++++ apps/landing/templates/landing/newsfeed.html | 22 + .../templates/landing/searchresults.html | 21 + apps/landing/templates/landing/web.html | 114 ++++ apps/landing/templates/sidebar/twitter.html | 23 + apps/landing/urls.py | 11 + apps/landing/views.py | 67 +++ lib/embedutils.py | 135 +++++ lib/utils.py | 115 ++++ lib/validate_jsonp.py | 211 ++++++++ migrations/mdn/01-initial-feeds.sql | 191 +++++++ migrations/mdn/02-feed-bundles.sql | 13 + migrations/mdn/03-updates-bundles.sql | 9 + migrations/mdn/04-blog-feeds.sql | 9 + migrations/mdn/05-about-mozilla-feed.sql | 6 + .../mdn/06-dekiwiki-recent-changes-feed.sql | 3 + migrations/mdn/07-apps-becomes-mozilla.sql | 4 + migrations/mdn/08-fix-mozilla-typo.sql | 1 + migrations/mdn/09-add-user-profiles-table.sql | 14 + migrations/mdn/10-demo-room-initial.sql | 148 ++++++ migrations/mdn/11-demo-room-comments.sql | 57 ++ .../mdn/12-demo-room-drop-tagdescriptions.sql | 4 + .../13-demo-room-actioncounters-rewrite.sql | 43 ++ ...-demo-room-comments-count-denormalized.sql | 7 + ...g-634390-utf8-collation-for-search-fix.sql | 30 ++ .../mdn/16-bug-634744-navbar-optout-field.sql | 10 + migrations/mdn/schematic_settings.py | 30 ++ settings.py | 107 +++- templates/base.html | 290 +++++----- templates/base_compact.html | 77 +++ templates/base_major.html | 9 + templates/includes/lang_switcher.html | 11 + templates/includes/login.html | 12 + templates/includes/video_player.html | 15 + templates/includes/webtrends.html | 33 ++ urls.py | 49 +- 120 files changed, 9100 insertions(+), 173 deletions(-) create mode 100644 apps/actioncounters/README.md create mode 100644 apps/actioncounters/__init__.py create mode 100644 apps/actioncounters/admin.py create mode 100644 apps/actioncounters/fields.py create mode 100644 apps/actioncounters/models.py create mode 100644 apps/actioncounters/tests.py create mode 100644 apps/actioncounters/utils.py create mode 100644 apps/actioncounters/views.py create mode 100644 apps/contentflagging/README.md create mode 100644 apps/contentflagging/__init__.py create mode 100644 apps/contentflagging/admin.py create mode 100644 apps/contentflagging/forms.py create mode 100644 apps/contentflagging/models.py create mode 100644 apps/contentflagging/tests.py create mode 100644 apps/contentflagging/utils.py create mode 100644 apps/dekicompat/Django_Notes.txt create mode 100644 apps/dekicompat/NOTES.txxt create mode 100644 apps/dekicompat/__init__.py create mode 100644 apps/dekicompat/auth.py.bak create mode 100644 apps/dekicompat/backends.py create mode 100644 apps/dekicompat/middleware.py create mode 100644 apps/dekicompat/tests.py create mode 100644 apps/dekicompat/views.py create mode 100644 apps/demos/__init__.py create mode 100644 apps/demos/admin.py create mode 100644 apps/demos/feeds.py create mode 100644 apps/demos/forms.py create mode 100644 apps/demos/helpers.py create mode 100644 apps/demos/models.py create mode 100644 apps/demos/templates/demos/base.html create mode 100644 apps/demos/templates/demos/base_popup.html create mode 100644 apps/demos/templates/demos/delete.html create mode 100644 apps/demos/templates/demos/delete_comment.html create mode 100644 apps/demos/templates/demos/detail.html create mode 100644 apps/demos/templates/demos/elements/comments_tree.html create mode 100644 apps/demos/templates/demos/elements/gravatar.html create mode 100644 apps/demos/templates/demos/elements/profile_link.html create mode 100644 apps/demos/templates/demos/elements/search_form.html create mode 100644 apps/demos/templates/demos/elements/submission_creator.html create mode 100644 apps/demos/templates/demos/elements/submission_listing.html create mode 100644 apps/demos/templates/demos/elements/submission_thumb.html create mode 100644 apps/demos/templates/demos/elements/tags_list.html create mode 100644 apps/demos/templates/demos/feed_item_description.html create mode 100644 apps/demos/templates/demos/flag.html create mode 100644 apps/demos/templates/demos/home.html create mode 100644 apps/demos/templates/demos/launch.html create mode 100644 apps/demos/templates/demos/listing_all.html create mode 100644 apps/demos/templates/demos/listing_search.html create mode 100644 apps/demos/templates/demos/listing_tag.html create mode 100644 apps/demos/templates/demos/profile_detail.html create mode 100644 apps/demos/templates/demos/submit.html create mode 100644 apps/demos/templates/demos/submit_noauth.html create mode 100644 apps/demos/templates/demos/terms.html create mode 100644 apps/demos/tests.py create mode 100644 apps/demos/urls.py create mode 100644 apps/demos/views.py create mode 100644 apps/devmo/__init__.py create mode 100644 apps/devmo/context_processors.py create mode 100644 apps/devmo/fixtures/initial_data.json create mode 100644 apps/devmo/helpers.py create mode 100644 apps/devmo/middleware.py create mode 100644 apps/devmo/models.py create mode 100644 apps/devmo/tests.py create mode 100644 apps/devmo/urlresolvers.py create mode 100644 apps/docs/__init__.py create mode 100644 apps/docs/cron.py create mode 100644 apps/docs/models.py create mode 100644 apps/docs/templates/docs/docs.html create mode 100644 apps/docs/templates/docs/glossary.html create mode 100644 apps/docs/urls.py create mode 100644 apps/docs/views.py create mode 100644 apps/feeder/__init__.py create mode 100644 apps/feeder/admin.py create mode 100644 apps/feeder/management/__init__.py create mode 100644 apps/feeder/management/commands/__init__.py create mode 100644 apps/feeder/management/commands/update_feeds.py create mode 100644 apps/feeder/models.py create mode 100644 apps/landing/__init__.py create mode 100644 apps/landing/helpers.py create mode 100644 apps/landing/models.py create mode 100644 apps/landing/templates/landing/addons.html create mode 100644 apps/landing/templates/landing/discussions.html create mode 100644 apps/landing/templates/landing/home.html create mode 100644 apps/landing/templates/landing/mobile.html create mode 100644 apps/landing/templates/landing/mozilla.html create mode 100644 apps/landing/templates/landing/newsfeed.html create mode 100644 apps/landing/templates/landing/searchresults.html create mode 100644 apps/landing/templates/landing/web.html create mode 100644 apps/landing/templates/sidebar/twitter.html create mode 100644 apps/landing/urls.py create mode 100644 apps/landing/views.py create mode 100644 lib/embedutils.py create mode 100644 lib/utils.py create mode 100644 lib/validate_jsonp.py create mode 100644 migrations/mdn/01-initial-feeds.sql create mode 100644 migrations/mdn/02-feed-bundles.sql create mode 100644 migrations/mdn/03-updates-bundles.sql create mode 100644 migrations/mdn/04-blog-feeds.sql create mode 100644 migrations/mdn/05-about-mozilla-feed.sql create mode 100644 migrations/mdn/06-dekiwiki-recent-changes-feed.sql create mode 100644 migrations/mdn/07-apps-becomes-mozilla.sql create mode 100644 migrations/mdn/08-fix-mozilla-typo.sql create mode 100644 migrations/mdn/09-add-user-profiles-table.sql create mode 100644 migrations/mdn/10-demo-room-initial.sql create mode 100644 migrations/mdn/11-demo-room-comments.sql create mode 100644 migrations/mdn/12-demo-room-drop-tagdescriptions.sql create mode 100644 migrations/mdn/13-demo-room-actioncounters-rewrite.sql create mode 100644 migrations/mdn/14-demo-room-comments-count-denormalized.sql create mode 100644 migrations/mdn/15-bug-634390-utf8-collation-for-search-fix.sql create mode 100644 migrations/mdn/16-bug-634744-navbar-optout-field.sql create mode 100644 migrations/mdn/schematic_settings.py create mode 100644 templates/base_compact.html create mode 100644 templates/base_major.html create mode 100644 templates/includes/lang_switcher.html create mode 100644 templates/includes/login.html create mode 100644 templates/includes/video_player.html create mode 100644 templates/includes/webtrends.html diff --git a/apps/actioncounters/README.md b/apps/actioncounters/README.md new file mode 100644 index 00000000000..48448bdb119 --- /dev/null +++ b/apps/actioncounters/README.md @@ -0,0 +1,10 @@ +actioncounters +============== + +This is a Django app intended to help count actions on model content objects. +Examples of actions include things such as "view", "download", and "like". +An attempt is made to constrain each counted action to a limit per unique user. + +Inspired in part by: +* +* diff --git a/apps/actioncounters/__init__.py b/apps/actioncounters/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/actioncounters/admin.py b/apps/actioncounters/admin.py new file mode 100644 index 00000000000..22b19d8f93f --- /dev/null +++ b/apps/actioncounters/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin + +from .models import ActionCounterUnique + +class ActionCounterUniqueAdmin(admin.ModelAdmin): + list_display = ( 'content_object', 'name', 'total', 'user', 'ip', + 'user_agent', 'modified', ) +admin.site.register(ActionCounterUnique, ActionCounterUniqueAdmin) diff --git a/apps/actioncounters/fields.py b/apps/actioncounters/fields.py new file mode 100644 index 00000000000..ebd949b20f8 --- /dev/null +++ b/apps/actioncounters/fields.py @@ -0,0 +1,130 @@ +"""Fields for action counters + +See also: djangoratings for inspiration +""" +import logging + +from django.conf import settings +from django.db import models +from django.db.models import F + + +RECENT_PERIOD = getattr(settings, 'ACTION_COUNTER_RECENT_PERIOD', 60 * 60 * 24) + + +class ActionCounterField(models.IntegerField): + """An action counter field contributes two columns to the model - one for + the current total count, and another for a recent history window count.""" + + def __init__(self, *args, **kwargs): + + self.max_total_per_unique = kwargs.pop('max_total_per_unique', 1) + self.min_total_per_unique = kwargs.pop('min_total_per_unique', 0) + + super(ActionCounterField, self).__init__(*args, **kwargs) + + def contribute_to_class(self, cls, name): + self.name = name + + self.total_field = models.IntegerField(editable=False, default=0, blank=True, db_index=True) + cls.add_to_class('%s_total' % (self.name,), self.total_field) + + self.recent_field = models.IntegerField(editable=False, default=0, blank=True, db_index=True) + cls.add_to_class('%s_recent' % (self.name,), self.recent_field) + + # TODO: Could maybe include a JSON-formatted history list of recent rollups + + t = ActionCounterCreator(self) + setattr(cls, name, t) + + def get_db_prep_save(self, value): + pass + + +class ActionCounterCreator(object): + def __init__(self, field): + self.field = field + self.votes_field_name = "%s_votes" % (self.field.name,) + self.score_field_name = "%s_score" % (self.field.name,) + + def __get__(self, instance, type=None): + if instance is None: + return self.field + return ActionCounterManager(instance, self.field) + + def __set__(self, instance, value): + raise TypeError("%s cannot be set directly") + + +class ActionCounterManager(object): + + def __init__(self, instance, field): + self.content_type = None + self.instance = instance + self.field = field + self.name = field.name + + self.total_field_name = "%s_total" % (self.name,) + self.recent_field_name = "%s_recent" % (self.name,) + + def _get_total(self, default=0): + return getattr(self.instance, self.total_field_name, default) + def _set_total(self, value): + return setattr(self.instance, self.total_field_name, value) + total = property(_get_total, _set_total) + + def _get_recent(self, default=0): + return getattr(self.instance, self.recent_field_name, default) + def _set_recent(self, value): + return setattr(self.instance, self.recent_field_name, value) + recent = property(_get_recent, _set_recent) + + def _get_counter_for_request(self, request, do_create=True): + from .models import ActionCounterUnique + counter, created = ActionCounterUnique.objects.get_unique_for_request( + self.instance, self.name, request, do_create) + return counter + + def get_total_for_request(self, request): + counter = self._get_counter_for_request(request, False) + return counter and counter.total or 0 + + def increment(self, request): + counter = self._get_counter_for_request(request) + ok = counter.increment(self.field.min_total_per_unique, + self.field.max_total_per_unique) + if ok: self._change_total(1) + + def decrement(self, request): + counter = self._get_counter_for_request(request) + ok = counter.decrement(self.field.min_total_per_unique, + self.field.max_total_per_unique) + if ok: self._change_total(-1) + + def _change_total(self, delta): + # This is ugly, but results in a single-column UPDATE like so: + # + # UPDATE `actioncounters_testmodel` + # SET `likes_total` = `actioncounters_testmodel`.`likes_total` + 1 + # WHERE `actioncounters_testmodel`.`id` = 1 + # + # This also avoids updating datestamps and doing a more verbose query. + # TODO: Find a less-ugly way to do this. + m_cls = self.instance.__class__ + qs = m_cls.objects.all().filter(pk=self.instance.pk) + update_kwargs = { + "%s" % self.total_field_name: F(self.total_field_name) + delta + } + qs.update(**update_kwargs) + + # HACK: This value change is just for the benefit of local code, + # may possibly fall out of sync with the actual database if there's a + # race condition. A subsequent save() could clobber concurrent counter + # changes. + self.total = self.total + delta + + # HACK: Invalidate this object in cache-machine, if the method is available. + if hasattr(m_cls.objects, 'invalidate'): + m_cls.objects.invalidate(self.instance) + + diff --git a/apps/actioncounters/models.py b/apps/actioncounters/models.py new file mode 100644 index 00000000000..0b28daff35b --- /dev/null +++ b/apps/actioncounters/models.py @@ -0,0 +1,113 @@ +"""Models for activity counters""" + +import logging + +from django.db import models +from django.conf import settings +from django.db.models import F + +from django.contrib.contenttypes.models import ContentType +from django.contrib.auth.models import User +from django.contrib.contenttypes import generic + +from django.utils.translation import ugettext_lazy as _ + +from .utils import get_ip, get_unique +from .fields import ActionCounterField + + +class TestModel(models.Model): + # TODO: Find a way to move this to tests.py and create only during testing! + + title = models.CharField(max_length=255, blank=False, unique=True) + + likes = ActionCounterField() + views = ActionCounterField(max_total_per_unique=5) + frobs = ActionCounterField(min_total_per_unique=-5) + boogs = ActionCounterField(min_total_per_unique=-5, max_total_per_unique=5) + + def __unicode__(self): + return unicode(self.pk) + + +class ActionCounterUniqueManager(models.Manager): + """Manager for action hits""" + + def get_unique_for_request(self, object, action_name, request, create=True): + """Get a unique counter for the given request, with the option to + refrain from creating a new one if the intent is just to check + existence.""" + content_type = ContentType.objects.get_for_model(object) + user, ip, user_agent, session_key = get_unique(request) + if create: + return self.get_or_create( + content_type=content_type, object_pk=object.pk, + name=action_name, + ip=ip, user_agent=user_agent, user=user, + session_key=session_key, + defaults=dict( total=0 )) + else: + try: + return ( + self.get( + content_type=content_type, object_pk=object.pk, + name=action_name, + ip=ip, user_agent=user_agent, user=user, + session_key=session_key,), + False + ) + except ActionCounterUnique.DoesNotExist: + return ( None, False ) + + +class ActionCounterUnique(models.Model): + """Action counter for a unique request / user""" + objects = ActionCounterUniqueManager() + + content_type = models.ForeignKey( + ContentType, + verbose_name="content type", + related_name="content_type_set_for_%(class)s",) + object_pk = models.CharField( _('object ID'), + max_length=32) + content_object = generic.GenericForeignKey( + 'content_type', 'object_pk') + name = models.CharField( _('name of the action'), + max_length=64, db_index=True, blank=False) + + total = models.IntegerField() + + ip = models.CharField(max_length=40, editable=False, + db_index=True, blank=True, null=True) + session_key = models.CharField(max_length=40, editable=False, + db_index=True, blank=True, null=True) + user_agent = models.CharField(max_length=255, editable=False, + db_index=True, blank=True, null=True) + user = models.ForeignKey(User, editable=False, + db_index=True, blank=True, null=True) + + modified = models.DateTimeField( + _('date last modified'), + auto_now=True, blank=False) + + def increment(self, min=0, max=1): + return self._change_total(1, min, max) + + def decrement(self, min=0, max=1): + return self._change_total(-1, min, max) + + def _change_total(self, delta, min, max): + # TODO: This seems like a race condition. Maybe there's a way to do something like + # UPDATE actioncounterunique SET total = min(max(field_total+1, min), max) WHERE ... + # ...and if that's doable, how to detect whether the total changed + result = (self.total + delta) + if result > max: return False + if result < min: return False + if result == 0: + # Don't keep zero entries around in the table. + self.delete() + else: + self.total = F('total') + delta + self.save() + return True + diff --git a/apps/actioncounters/tests.py b/apps/actioncounters/tests.py new file mode 100644 index 00000000000..245bf94e762 --- /dev/null +++ b/apps/actioncounters/tests.py @@ -0,0 +1,197 @@ +import logging +import time + +from django.conf import settings +from django.db import connection + +from django.contrib.auth.models import AnonymousUser + +from django.http import HttpRequest +from django.test import TestCase +from django.test.client import Client + +from django.contrib.auth.models import User +from django.contrib.sessions.models import Session + +from nose.tools import assert_equal, with_setup, assert_false, eq_, ok_ +from nose.plugins.attrib import attr + +from django.db import models + +from .models import TestModel +from .fields import ActionCounterField + + +class ActionCountersTest(TestCase): + + def setUp(self): + settings.DEBUG = True + + self.user1 = User.objects.create_user( + 'tester1', 'tester2@tester.com', 'tester1') + self.user1.save() + + self.user2 = User.objects.create_user( + 'tester2', 'tester2@tester.com', 'tester2') + self.user2.save() + + self.obj_1 = TestModel(title="alpha") + self.obj_1.save() + + def tearDown(self): + #for sql in connection.queries: + # logging.debug("SQL %s" % sql) + pass + + def mk_request(self, user=None, session_key=None, ip='192.168.123.123', + user_agent='FakeBrowser 1.0'): + request = HttpRequest() + request.user = user and user or AnonymousUser() + if session_key: + request.session = Session() + request.session.session_key = session_key + request.method = 'GET' + request.META['REMOTE_ADDR'] = ip + request.META['HTTP_USER_AGENT'] = user_agent + return request + + def test_basic_action_increment(self): + """Action attempted with several different kinds of unique identifiers""" + + obj_1 = self.obj_1 + + # set up request for anonymous user #1 + request = self.mk_request() + + # anonymous user #1 likes user2 + obj_1.likes.increment(request) + eq_(1, obj_1.likes.total) + + # anonymous user #1 likes user2, again + obj_1.likes.increment(request) + eq_(1, obj_1.likes.total) + + # set up request for anonymous user #2 + request = self.mk_request(ip='192.168.123.1') + + # anonymous user #2 likes user2 + obj_1.likes.increment(request) + eq_(2, obj_1.likes.total) + + # anonymous user #2 likes user2, again + obj_1.likes.increment(request) + eq_(2, obj_1.likes.total) + + # set up request for authenticated user1 + request = self.mk_request(user=self.user1) + + # authenticated user1 likes user2 + obj_1.likes.increment(request) + eq_(3, obj_1.likes.total) + + # authenticated user1 likes user2, again + obj_1.likes.increment(request) + eq_(3, obj_1.likes.total) + + # authenticated user1 likes user2, again, from another IP + request.META['REMOTE_ADDR'] = '192.168.123.50' + obj_1.likes.increment(request) + eq_(3, obj_1.likes.total) + + # set up request for user agent Mozilla 1.0 + request = self.mk_request(ip='192.168.123.50', user_agent='Mozilla 1.0') + obj_1.likes.increment(request) + eq_(4, obj_1.likes.total) + + # set up request for user agent Safari 1.0 + request = self.mk_request(ip='192.168.123.50', user_agent='Safari 1.0') + obj_1.likes.increment(request) + eq_(5, obj_1.likes.total) + + def test_action_with_max(self): + """Action with a max_total_per_unique greater than 1""" + obj_1 = self.obj_1 + MAX = obj_1.views.field.max_total_per_unique + + request = self.mk_request(ip='192.168.123.123') + for x in range(1, MAX+1): + obj_1.views.increment(request) + eq_(x, obj_1.views.total) + + obj_1.views.increment(request) + eq_(MAX, obj_1.views.total) + + obj_1.views.increment(request) + eq_(MAX, obj_1.views.total) + + def test_action_with_min(self): + """Action with a min_total_per_unique greater than 1""" + obj_1 = self.obj_1 + MIN = obj_1.frobs.field.min_total_per_unique + + request = self.mk_request(ip='192.168.123.123') + for x in range(1, (0-MIN)+1): + obj_1.frobs.decrement(request) + eq_(0-x, obj_1.frobs.total) + + obj_1.frobs.decrement(request) + eq_(MIN, obj_1.frobs.total) + + obj_1.frobs.decrement(request) + eq_(MIN, obj_1.frobs.total) + + def test_action_count_per_unique(self): + """Exercise action counts per unique and ensure overall total works""" + obj_1 = self.obj_1 + + MAX = obj_1.boogs.field.max_total_per_unique + MIN = obj_1.boogs.field.min_total_per_unique + + UNIQUES = ( + dict(user=self.user1), + dict(user=self.user2), + dict(ip='192.168.123.123'), + dict(ip='192.168.123.150', user_agent="Safari 1.0"), + dict(ip='192.168.123.150', user_agent="Mozilla 1.0"), + dict(ip='192.168.123.160'), + ) + + for unique in UNIQUES: + request = self.mk_request(**unique) + + for x in range(1, MAX+1): + obj_1.boogs.increment(request) + eq_(x, obj_1.boogs.get_total_for_request(request)) + + obj_1.boogs.increment(request) + obj_1.boogs.increment(request) + eq_(MAX, obj_1.boogs.get_total_for_request(request)) + + eq_(MAX * len(UNIQUES), obj_1.boogs.total) + + # Undo all the increments before going below zero + for unique in UNIQUES: + request = self.mk_request(**unique) + for x in range(1, MAX+1): + obj_1.boogs.decrement(request) + + for unique in UNIQUES: + request = self.mk_request(**unique) + + for x in range(1, (0-MIN)+1): + obj_1.boogs.decrement(request) + eq_(0-x, obj_1.boogs.get_total_for_request(request)) + + obj_1.boogs.decrement(request) + obj_1.boogs.decrement(request) + eq_(MIN, obj_1.boogs.get_total_for_request(request)) + + eq_(MIN * len(UNIQUES), obj_1.boogs.total) + + def test_count_starts_at_zero(self): + """Make sure initial count is zero. + + Sounds dumb, but it was a bug at one point.""" + request = self.mk_request() + eq_(0, self.obj_1.likes.get_total_for_request(request)) + diff --git a/apps/actioncounters/utils.py b/apps/actioncounters/utils.py new file mode 100644 index 00000000000..91df3a3a708 --- /dev/null +++ b/apps/actioncounters/utils.py @@ -0,0 +1,66 @@ +from django.conf import settings +import re +import logging + +# this is not intended to be an all-knowing IP address regex +IP_RE = re.compile('\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}') + +def get_ip(request): + """ + Retrieves the remote IP address from the request data. If the user is + behind a proxy, they may have a comma-separated list of IP addresses, so + we need to account for that. In such a case, only the first IP in the + list will be retrieved. Also, some hosts that use a proxy will put the + REMOTE_ADDR into HTTP_X_FORWARDED_FOR. This will handle pulling back the + IP from the proper place. + + **NOTE** This function was taken from django-tracking (MIT LICENSE) + http://code.google.com/p/django-tracking/ + """ + + # if neither header contain a value, just use local loopback + ip_address = request.META.get('HTTP_X_FORWARDED_FOR', + request.META.get('REMOTE_ADDR', '127.0.0.1')) + if ip_address: + # make sure we have one and only one IP + try: + ip_address = IP_RE.match(ip_address) + if ip_address: + ip_address = ip_address.group(0) + else: + # no IP, probably from some dirty proxy or other device + # throw in some bogus IP + ip_address = '10.0.0.1' + except IndexError: + pass + + return ip_address + + +def get_unique(request, use_session_key=False): + """Extract a set of unique identifiers from the request. + + This set will be made up of one of the following combinations, depending + on what's available: + + * user, None, None, None + * None, None, None, session_key + * None, ip, user_agent, None + """ + if request.user.is_authenticated(): + user = request.user + ip = user_agent = session_key = None + else: + user = None + session_key = ( + ( use_session_key and hasattr(request, 'session') ) and + request.session.session_key or None ) + if session_key: + ip = user_agent = None + else: + ip = get_ip(request) + user_agent = request.META.get('HTTP_USER_AGENT', '')[:255] + + return ( user, ip, user_agent, session_key ) + + diff --git a/apps/actioncounters/views.py b/apps/actioncounters/views.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/contentflagging/README.md b/apps/contentflagging/README.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/contentflagging/__init__.py b/apps/contentflagging/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/contentflagging/admin.py b/apps/contentflagging/admin.py new file mode 100644 index 00000000000..0bf5d7b92f5 --- /dev/null +++ b/apps/contentflagging/admin.py @@ -0,0 +1,13 @@ +from django.contrib import admin + +from .models import ContentFlag + + +class ContentFlagAdmin(admin.ModelAdmin): + list_display = ( 'created', 'content_view_link', 'content_admin_link', + 'flag_status', 'flag_type', 'explanation', ) + list_editable = ( 'flag_status', ) + list_filter = ( 'flag_status', 'flag_type', ) + list_select_related = True + +admin.site.register(ContentFlag, ContentFlagAdmin) diff --git a/apps/contentflagging/forms.py b/apps/contentflagging/forms.py new file mode 100644 index 00000000000..e4a92352e4f --- /dev/null +++ b/apps/contentflagging/forms.py @@ -0,0 +1,41 @@ +from hashlib import md5 + +from django import forms + +from django.conf import settings + +from django.utils.translation import ugettext_lazy as _ +from django.contrib.auth.models import User +from django.db import models + +import django.forms.fields + +from .models import ContentFlag, FLAG_REASONS + + +class MyModelForm(forms.ModelForm): + def as_ul(self): + "Returns this form rendered as HTML
  • s -- excluding the
      ." + return self._html_output( + normal_row = u'%(label)s %(field)s%(help_text)s%(errors)s
    • ', + error_row = u'
    • %s
    • ', + row_ender = '', + help_text_html = u'

      %s

      ', + errors_on_separate_row = False) + + +class ContentFlagForm(MyModelForm): + """Form for accepting a content moderation flag submission""" + + class Meta: + model = ContentFlag + fields = ( 'flag_type', 'explanation' ) + + flag_type = forms.ChoiceField( + choices=FLAG_REASONS, + widget=forms.RadioSelect) + + def clean(self): + cleaned_data = super(ContentFlagForm, self).clean() + return cleaned_data + diff --git a/apps/contentflagging/models.py b/apps/contentflagging/models.py new file mode 100644 index 00000000000..3da94b2b80e --- /dev/null +++ b/apps/contentflagging/models.py @@ -0,0 +1,125 @@ +"""Models for content moderation flagging""" +import logging + +from django.db import models +from django.conf import settings +from django.db.models import F + +from django.core import urlresolvers + +from django.contrib.contenttypes.models import ContentType +from django.contrib.auth.models import User +from django.contrib.contenttypes import generic + +from django.utils.translation import ugettext_lazy as _ + +from .utils import get_ip, get_unique + + +FLAG_REASONS = getattr(settings, "FLAG_REASONS", ( + ('notworking', _('This is not working for me')), + ('inappropriate', _('This contains inappropriate content')), + ('plagarised', _('This was not created by the author')), + ('fakeauthor', _('The author is fake')), +)) + +FLAG_STATUSES = getattr(settings, "FLAG_STATUSES", ( + ("flagged", _("Flagged")), + ("rejected", _("Flag rejected by moderator")), + ("notified", _("Creator notified")), + ("hidden", _("Content hidden by moderator")), + ("deleted", _("Content deleted by moderator")), +)) + + +class ContentFlagManager(models.Manager): + """Manager for ContentFlags""" + + def flag(self, request, object, flag_type, explanation): + """Create a flag for a content item, if the unique request hasn't + already done so before.""" + if flag_type not in dict(FLAG_REASONS): + return (None, False) + + user, ip, user_agent, session_key = get_unique(request) + + content_type = ContentType.objects.get_for_model(object) + + return ContentFlag.objects.get_or_create( + content_type=content_type, object_pk=object.pk, + ip=ip, user_agent=user_agent, user=user, session_key=session_key, + defaults=dict(flag_type=flag_type, explanation=explanation,)) + + +class ContentFlag(models.Model): + """Moderation flag submitted against a content item""" + objects = ContentFlagManager() + + class Meta: + ordering = ( '-created', ) + get_latest_by = 'created' + unique_together = ( ('content_type', 'object_pk', + 'ip','session_key','user_agent','user'), ) + + flag_status = models.CharField( + _('current status of flag review'), + max_length=16, blank=False, choices=FLAG_STATUSES, default='flagged') + flag_type = models.CharField( + _('reason for flagging the content'), + max_length=64, db_index=True, blank=False, choices=FLAG_REASONS) + explanation = models.TextField( + _('please explain what content you feel is inappropriate'), + max_length=255, blank=True) + content_type = models.ForeignKey( + ContentType, editable=False, + verbose_name="content type", + related_name="content_type_set_for_%(class)s",) + object_pk = models.CharField( + _('object ID'), + max_length=32, editable=False) + content_object = generic.GenericForeignKey( + 'content_type', 'object_pk') + + ip = models.CharField( + max_length=40, editable=False, + blank=True, null=True) + session_key = models.CharField( + max_length=40, editable=False, + blank=True, null=True) + user_agent = models.CharField( + max_length=255, editable=False, + blank=True, null=True) + user = models.ForeignKey( + User, editable=False, blank=True, null=True) + + created = models.DateTimeField( + _('date submitted'), + auto_now_add=True, blank=False, editable=False) + modified = models.DateTimeField( + _('date last modified'), + auto_now=True, blank=False) + + def __unicode__(self): + return 'ContentFlag %(flag_type)s -> "%(title)s"' % dict( + flag_type=self.flag_type, title=str(self.content_object)) + + def content_view_link(self): + """HTML link to the absolute URL for the linked content object""" + object = self.content_object + return ( 'View %(title)s' % + dict(link=object.get_absolute_url(), title=object) ) + + content_view_link.allow_tags = True + + def content_admin_link(self): + """HTML link to the admin page for the linked content object""" + object = self.content_object + ct = ContentType.objects.get_for_model(object) + url_name = 'admin:%(app)s_%(model)s_change' % dict( + app=ct.app_label, model=ct.model) + link = urlresolvers.reverse(url_name, args=(object.id,)) + return ( 'Edit %(title)s' % + dict(link=link, title=object) ) + + content_admin_link.allow_tags = True + diff --git a/apps/contentflagging/tests.py b/apps/contentflagging/tests.py new file mode 100644 index 00000000000..915aced9d12 --- /dev/null +++ b/apps/contentflagging/tests.py @@ -0,0 +1,84 @@ +import logging +import time + +from django.conf import settings +from django.db import connection + +from django.contrib.auth.models import AnonymousUser + +from django.http import HttpRequest +from django.test import TestCase +from django.test.client import Client + +from django.contrib.auth.models import User +from django.contrib.sessions.models import Session + +from nose.tools import assert_equal, with_setup, assert_false, eq_, ok_ +from nose.plugins.attrib import attr + +from .models import ContentFlag + +class DemoPackageTest(TestCase): + + def setUp(self): + settings.DEBUG = True + + self.user1 = User.objects.create_user('tester1', + 'tester2@tester.com', 'tester1') + self.user1.save() + + self.user2 = User.objects.create_user('tester2', + 'tester2@tester.com', 'tester2') + self.user2.save() + + def tearDown(self): + #for sql in connection.queries: + # logging.debug("SQL %s" % sql) + pass + + def mk_request(self, user=None, session_key=None, ip='192.168.123.123', + user_agent='FakeBrowser 1.0'): + request = HttpRequest() + request.user = user and user or AnonymousUser() + if session_key: + request.session = Session() + request.session.session_key = session_key + request.method = 'GET' + request.META['REMOTE_ADDR'] = ip + request.META['HTTP_USER_AGENT'] = user_agent + return request + + def test_basic_flag(self): + """Exercise flagging with limit of one per unique request per unique object""" + + # Submit a flag. + request = self.mk_request() + flag, created = ContentFlag.objects.flag(request=request, object=self.user2, + flag_type='notworking', explanation="It not go.") + eq_(True, created) + + # One flag instance per unique user + flag, created = ContentFlag.objects.flag(request=request, object=self.user2, + flag_type='notworking', explanation="It really not go!") + eq_(False, created) + + # Submit a flag on another object. + request = self.mk_request() + flag, created = ContentFlag.objects.flag(request=request, object=self.user1, + flag_type='notworking', explanation="It not go.") + eq_(True, created) + + # Try another unique request + request = self.mk_request(ip='192.168.123.1') + flag, created = ContentFlag.objects.flag(request=request, object=self.user2, + flag_type='inappropriate', explanation="This is porn.") + eq_(True, created) + + request = self.mk_request(ip='192.168.123.50', user_agent='Mozilla 1.0') + flag, created = ContentFlag.objects.flag(request=request, object=self.user2, + flag_type='inappropriate', explanation="This is porn.") + eq_(True, created) + + eq_(4, len(ContentFlag.objects.all())) + + diff --git a/apps/contentflagging/utils.py b/apps/contentflagging/utils.py new file mode 100644 index 00000000000..c1c2da9e41b --- /dev/null +++ b/apps/contentflagging/utils.py @@ -0,0 +1,67 @@ +from django.conf import settings +import re +import logging + +# this is not intended to be an all-knowing IP address regex +IP_RE = re.compile('\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}') + +def get_ip(request): + """ + Retrieves the remote IP address from the request data. If the user is + behind a proxy, they may have a comma-separated list of IP addresses, so + we need to account for that. In such a case, only the first IP in the + list will be retrieved. Also, some hosts that use a proxy will put the + REMOTE_ADDR into HTTP_X_FORWARDED_FOR. This will handle pulling back the + IP from the proper place. + + **NOTE** This function was taken from django-tracking (MIT LICENSE) + http://code.google.com/p/django-tracking/ + """ + + # if neither header contain a value, just use local loopback + ip_address = request.META.get('HTTP_X_FORWARDED_FOR', + request.META.get('REMOTE_ADDR', '127.0.0.1')) + if ip_address: + # make sure we have one and only one IP + try: + ip_address = IP_RE.match(ip_address) + if ip_address: + ip_address = ip_address.group(0) + else: + # no IP, probably from some dirty proxy or other device + # throw in some bogus IP + ip_address = '10.0.0.1' + except IndexError: + pass + + return ip_address + + +def get_unique(request, use_session_key=False): + """Extract a set of unique identifiers from the request. + + This set will be made up of one of the following combinations, depending + on what's available: + + * user, None, None, None + * None, None, None, session_key + * None, ip, user_agent, None + """ + if request.user.is_authenticated(): + user = request.user + ip = user_agent = session_key = None + else: + user = None + session_key = ( + ( use_session_key and hasattr(request, 'session') ) and + request.session.session_key or None ) + if session_key: + ip = user_agent = None + else: + ip = get_ip(request) + user_agent = request.META.get('HTTP_USER_AGENT', '')[:255] + + return ( user, ip, user_agent, session_key ) + + + diff --git a/apps/dekicompat/Django_Notes.txt b/apps/dekicompat/Django_Notes.txt new file mode 100644 index 00000000000..4859c3a9910 --- /dev/null +++ b/apps/dekicompat/Django_Notes.txt @@ -0,0 +1,12 @@ +Django Notes + +Authentication - users/authenticate + +Session to user - users/current (pass cookie through) + +When we create the Django +set_unusable_password() + +https://developer.mozilla.org/index.php?title=Special:Userlogin&returntotitle=en%2FJavaScript + +Special:Userregistration doesn't support redirecting... diff --git a/apps/dekicompat/NOTES.txxt b/apps/dekicompat/NOTES.txxt new file mode 100644 index 00000000000..fb9ab02aa8a --- /dev/null +++ b/apps/dekicompat/NOTES.txxt @@ -0,0 +1,38 @@ +curl -u ozten:pass http://dekiwiki/@api/deki/users/authenticate + +250161_634271549121737750_06554df2ea80477cc128b1746cd4f003 +{user_id} {???} {???} + + Set-Cookie: authtoken="250161_634271571075577310_308ce16068cc96b43d3298abf8598c76"d; Expires=Sun, 12-Dec-2010 14:45:07 GMT; Version=1; Path=/ + + +Fun fact... Using Set-Cookie, we'll have auth compatibility??? + +'authtoken="250161_634271587972625460_2c9228bad2516db205f77101cdff5067"' + +curl -v -b 'authtoken="250161_634271587972625460_2c9228bad2516db205f77101cdff5067"' http://dekiwiki/@api/deki/users/current +* About to connect() to dekiwiki port 80 (#0) +* Trying 192.168.13.135... connected +* Connected to dekiwiki (192.168.13.135) port 80 (#0) +> GET /@api/deki/users/current HTTP/1.1 +> User-Agent: curl/7.19.7 (i486-pc-linux-gnu) libcurl/7.19.7 OpenSSL/0.9.8k zlib/1.2.3.3 libidn/1.15 +> Host: dekiwiki +> Accept: */* +> Cookie: authtoken="250161_634271587972625460_2c9228bad2516db205f77101cdff5067" +> +< HTTP/1.1 200 OK +< Date: Sun, 05 Dec 2010 15:14:06 GMT +< Server: Dream-HTTPAPI/2.0.3.19504 +< X-Deki-Site: id="default" +< X-Data-Stats: request-time-ms=6; mysql-queries=7; mysql-time-ms=3; +< Content-Type: application/xml; charset=utf-8 +< Content-Length: 1299 +< Via: 1.1 dekiwiki + +oztenoztenshout@ozten.com4021c2acfc5b98b6dfe2d0ec26432ce1http://www.gravatar.com/avatar/4021c2acfc5b98b6dfe2d0ec26432ce1.png2010-08-26T22:39:15Zhttp://dekiwiki/User:oztenUser:oztenUser:oztenuseractive2010-12-05T15:13:17Z + LOGIN,BROWSE,READ,SUBSCRIBE,UPDATE,CREATE,DELETE,CHANGEPERMISSIONS,UNSAFECONTENT,ADMIN + Admin + LOGIN,BROWSE,READ,SUBSCRIBE,UPDATE,CREATE,DELETE,CHANGEPERMISSIONS,UNSAFECONTENT,ADMIN + + (mdn)ozten@nutria:~/Projects/MDN/mdn/apps/dekicompat$ + diff --git a/apps/dekicompat/__init__.py b/apps/dekicompat/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/dekicompat/auth.py.bak b/apps/dekicompat/auth.py.bak new file mode 100644 index 00000000000..3e883878968 --- /dev/null +++ b/apps/dekicompat/auth.py.bak @@ -0,0 +1,68 @@ +from urllib2 import HTTPPasswordMgrWithDefaultRealm, HTTPBasicAuthHandler, build_opener, urlopen, install_opener, HTTPCookieProcessor + +top_level_url = "http://dekiwiki/@api/deki/users/authenticate" +username = "ozten" +password = "pass" + +# create a password manager +password_mgr = HTTPPasswordMgrWithDefaultRealm() + +# Add the username and password. +# If we knew the realm, we could use it instead of ``None``. +password_mgr.add_password(None, top_level_url, username, password) + + +handler = HTTPBasicAuthHandler(password_mgr) + +# create "opener" (OpenerDirector instance) +opener = build_opener(handler) + +# Install the opener. +# Now all calls to urllib2.urlopen use our opener. +#install_opener(opener) + +resp = opener.open(top_level_url) +authtoken = resp.read() +print("We got an authoken %s" % authtoken) + + +#import cookielib, Cookie +#cj = cookielib.CookieJar() +#cj.set_cookie(Cookie.SimpleCookie({'authtoken': authtoken})) +#processor = HTTPCookieProcessor(cj) +#opener = build_opener(processor) + +opener = build_opener() +opener.addheaders = [('Cookie', ("authtoken=\"%s\"" % authtoken))] +resp = opener.open("http://dekiwiki/@api/deki/users/current") +print(str(resp)) +profile = resp.read() +print("We got an profile %s" % profile) + +from xml.dom import minidom +xmldoc = minidom.parse(profile) +userEl = None +username = None +deki_user_id = 0 +deki_fullname = "" + +for c in xmldoc.childNodes: + if c.nodeName == 'user': + userEl = c + break +for c in userEl.childNodes: + if c.nodeName == 'username': + username = c.firstChild.nodeValue + break +Anonymous + Anonymous + d41d8cd98f00b204e9800998ecf8427e + http://www.gravatar.com/avatar/d41d8cd98f00b204e9800998ecf8427e.png + 2005-03-15T23:42:24Z + active + 2010-12-05T21:27:31Z + + + + LOGIN,BROWSE,READ,SUBSCRIBEViewerLOGIN,BROWSE,READ,SUBSCRIBE diff --git a/apps/dekicompat/backends.py b/apps/dekicompat/backends.py new file mode 100644 index 00000000000..4f2ef0f1e35 --- /dev/null +++ b/apps/dekicompat/backends.py @@ -0,0 +1,185 @@ +from urllib2 import build_opener, HTTPError +from xml.dom import minidom + +from django.conf import settings +from django.contrib.auth.models import User + +import commonware + +from devmo.models import UserProfile + +log = commonware.log.getLogger('mdn.dekicompat') + +class DekiUserBackend(object): + """ + This backend is to be used in conjunction with the + ``DekiUserMiddleware`` to authenticate via Dekiwiki. + + Tips for faking out Django/Dekiwiki https://intranet.mozilla.org/Webdev:MDN:DjangoAuth + """ + profile_url = "%s/@api/deki/users/current" % settings.DEKIWIKI_ENDPOINT + profile_by_id_url = "%s/@api/deki/users/%s" % ( settings.DEKIWIKI_ENDPOINT, '%s' ) + + def authenticate(self, authtoken): + """ + We delegate to dekiwiki via an authtoken. + """ + user = None + opener = build_opener() + auth_cookie = 'authtoken="%s"' % authtoken + opener.addheaders = [('Cookie', auth_cookie),] + resp = opener.open(DekiUserBackend.profile_url) + deki_user = DekiUser.parse_user_info(resp.read()) + if deki_user: + log.info("MONITOR Dekiwiki Auth Success") + return self.get_or_create_user(deki_user) + else: + log.info("MONITOR Dekiwiki Failed") + return None + + def get_deki_user(self, deki_user_id): + """Fetch details for a given Dekiwiki profile by user ID""" + opener = build_opener() + resp = opener.open(DekiUserBackend.profile_by_id_url % deki_user_id) + return DekiUser.parse_user_info(resp.read()) + + def get_user(self, user_id): + """Get a user for a given ID, used by auth for session-cached login""" + try: + user = User.objects.get(pk=user_id) + profile = UserProfile.objects.get(user=user) + user.deki_user = self.get_deki_user(profile.deki_user_id) + return user + except User.DoesNotExist: + return None + except HTTPError: + return None + + def get_or_create_user(self, deki_user): + """ + Grab the User via their UserProfile and deki_user_id. + If non exists, create both. + + NOTE: Changes to this method may require changes to + parse_user_info + """ + try: + profile = UserProfile.objects.get(deki_user_id=deki_user.id) + user = profile.user + log.info("MONITOR Dekiwiki Profile Loaded") + log.debug("User account already exists %d", user.id) + except UserProfile.DoesNotExist: + log.debug("First time seeing deki user id#%d username=%s, creating account locally", deki_user.id, deki_user.username) + user, created = User.objects.get_or_create(username=deki_user.username) + + user.username = deki_user.username + # HACK: Deki has fullname Django has First and last... + # WACK: but our Dekiwiki instace doesn't let the user edit this data + user.first_name = deki_user.fullname + user.last_name = '' + user.set_unusable_password() + user.save() + profile = UserProfile(deki_user_id = deki_user.id, user=user) + profile.save() + log.info("MONITOR Dekiwiki Profile Saved") + log.debug("Saved profile %s", str(profile)) + user.deki_user = deki_user + + # Items we don't store in our DB (API read keeps it fresh) + user.is_superuser = deki_user.is_superuser + user.is_staff = deki_user.is_staff + user.is_active = deki_user.is_active + return user + +class DekiUser(object): + """ + Simple data type for deki user info + """ + def __init__(self, id, username, fullname, email, gravitar, profile_url=None): + self.id = id + self.username = username + self.fullname = fullname + self.email = email + self.gravitar = gravitar + self.profile_url = profile_url + self.is_active = False + self.is_staff = False + self.is_superuser = False + + @staticmethod + def parse_user_info(xmlstring): + """ + Parses XML and creates a DekiUser instance. + If the user is Anonymous returns None. + if the user is logged in, returns the DekiUser + instace. + + NOTE: Updating this method may require changes to + get_or_create_user + + TODO: Flesh out with more properties as needed. + In the future we can support is_active, groups, etc. + + Example output form Deki: + + Anonymous + Anonymous + + + d41d8cd98f00b204e9800998ecf8427e + http://www.gravatar.com/avatar/d41d8cd98f00b204e9800998ecf8427e.png + 2005-03-15T23:42:24Z + active + 2010-12-05T21:27:31Z + + + + LOGIN,BROWSE,READ,SUBSCRIBEViewer + LOGIN,BROWSE,READ,SUBSCRIBE + + + + """ + xmldoc = minidom.parseString(xmlstring) + deki_user = DekiUser(-1, 'Anonymous', '', '', 'http://www.gravatar.com/avatar/d41d8cd98f00b204e9800998ecf8427e.png') + + userEl = None + + for c in xmldoc.childNodes: + if c.nodeName == 'user': + userEl = c + break + if not userEl: + return None + deki_user.id = int(userEl.getAttribute('id')) + log.debug("Seeing user id %d", deki_user.id) + + deki_user.xml = xmlstring + + for c in userEl.childNodes: + if 'username' == c.nodeName and c.firstChild: + deki_user.username = c.firstChild.nodeValue + elif 'fullname' == c.nodeName and c.firstChild: + deki_user.fullname = c.firstChild.nodeValue + elif 'email' == c.nodeName and c.firstChild: + deki_user.email = c.firstChild.nodeValue + elif 'uri.gravatar' == c.nodeName and c.firstChild: + deki_user.gravitar = c.firstChild.nodeValue + elif 'page.home' == c.nodeName: + for sc in c.childNodes: + if 'uri.ui' == sc.nodeName and sc.firstChild: + deki_user.profile_url = sc.firstChild.nodeValue + elif 'status' == c.nodeName: + if 'active' == c.firstChild.nodeValue: + deki_user.is_active = True + elif 'permissions.user' == c.nodeName: + for sc in c.childNodes: + if 'role' == sc.nodeName: + if 'Admin' == sc.firstChild.nodeValue: + deki_user.is_staff = True + deki_user.is_superuser = True + + if 'Anonymous' == deki_user.username: + return None + else: + return deki_user diff --git a/apps/dekicompat/middleware.py b/apps/dekicompat/middleware.py new file mode 100644 index 00000000000..40022d4241c --- /dev/null +++ b/apps/dekicompat/middleware.py @@ -0,0 +1,47 @@ +from django.core.exceptions import ImproperlyConfigured + +from django.contrib import auth + +import commonware + +log = commonware.log.getLogger('mdn.dekicompat') + +class DekiUserMiddleware(object): + """ + This middleware is to be used in conjunction with the + ``DekiUserBackend`` to authenticate via Dekiwiki. + """ + def process_request(self, request): + """ + TODO This is a good check and was working until I went to commit... + + # AuthenticationMiddleware is required so that request.user exists. + if not hasattr(request, 'user'): + raise ImproperlyConfigured( + "The Dekicombat auth middleware requires the" + " authentication middleware to be installed. Edit your" + " MIDDLEWARE_CLASSES setting to insert" + " 'django.contrib.auth.middleware.AuthenticationMiddleware'" + " before the DekiUserMiddleware class.") + """ + try: + auth_token = request.COOKIES['authtoken'] + log.debug("middleware seeing authtoken=%s", auth_token) + except KeyError: + # If specified header doesn't exist then return (leaving + # request.user set to AnonymousUser by the + # AuthenticationMiddleware). + log.debug("middleware no authtoken cookie, skipping") + return + # TODO(aok) Session or other cache? + if not auth_token: + return + + # We are seeing this user for the first time in this session, attempt + # to authenticate the user. + user = auth.authenticate(authtoken=auth_token) + if user: + # User is valid. Set request.user and persist user in the session + # by logging the user in. + request.user = user + auth.login(request, user) diff --git a/apps/dekicompat/tests.py b/apps/dekicompat/tests.py new file mode 100644 index 00000000000..0d59193b15d --- /dev/null +++ b/apps/dekicompat/tests.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +from urllib2 import HTTPPasswordMgrWithDefaultRealm, HTTPBasicAuthHandler, build_opener, urlopen, install_opener +import re + +from django.contrib.auth.models import AnonymousUser, User + +from test_utils import TestCase + +import commonware + +from devmo.models import UserProfile +from dekicompat.backends import DekiUserBackend, DekiUser + +log = commonware.log.getLogger('mdn.dekicompat') + +class DekiCompatTestCase(TestCase): + """ + TODO: Some tests depend on A Dekiwiki server. Should we mock out + urllib2 instead? + + TODO Why doesn't Django see the test fixtures? + Workaround: devmo/fixtures/initial_data.json + fixtures = ['dekicompat/users',] + """ + + # Don't use settings.DEKIWIKI_ENDPOINT tests always point at stage9... + stage_endpoint = 'http://developer-stage9.mozilla.org' + auth_url = "%s/@api/deki/users/authenticate" % stage_endpoint + username = 'test6' + password = 'password' + #TODO if username/password is bad, this causes python 2.6.5 to RuntimeError: maximum recursion depth exceeded while calling a Python object + + def setUp(self): + self.authtoken_re = re.compile("\d+_\d+_[0-9A-Fa-f]+") + + def test_anonymous_request(self): + "User doesn't have a deki authtoken Cookie." + c = self.client + r = c.get('/en-US/') + + user = r.context['request'].user + self.assertEquals(True, user.is_anonymous()) + + def test_good_deki_authtoken_request(self): + """ + User was logged into Dekiwiki and so they are sending + an authtoken cookie. Middleware and Backend should + be able to verify this token and load the user. + """ + c = self.client + c.cookies['authtoken'] = self.login_stage() + r = c.get('/en-US/') + + user = r.context['request'].user + self.assertEquals(False, user.is_anonymous()) + self.assertEquals(DekiCompatTestCase.username, user.username) + users = User.objects.filter(username=DekiCompatTestCase.username) + if not users: + self.fail("Unable to retrieve User object") + user = users[0] + + profile = user.get_profile() + if not profile: + self.fail("Unable to get user's profile") + + # Make sure we got back a new user and not 2 from the app/devmo/fixtures + self.assertTrue(user.id > 2) + self.assertTrue(profile.id > 3) + self.assertTrue(profile.deki_user_id > 13) + + def test_bad_deki_authtoken_request(self): + """ + Tries to authenticate a stale or bad authtoken + """ + c = self.client + c.cookies['authtoken'] = "42_666000666000666000_501e0057abbed0000be5e0005elf" + r = c.get('/en-US/') + + user = r.context['request'].user + self.assertEquals(True, user.is_anonymous()) + + def login_stage(self): + """ + Using the hardcoded username and password + Attempts Dekiwiki login and returns an authtoken + Example authtoken: + 252017_634285545555468650_3b7d87b75c5b0c0626ad8c9884e4398f + """ + auth_url = self.__class__.auth_url + username = self.__class__.username + password = self.__class__.password + + password_mgr = HTTPPasswordMgrWithDefaultRealm() + password_mgr.add_password(None, auth_url, username, password) + handler = HTTPBasicAuthHandler(password_mgr) + opener = build_opener(handler) + + resp = opener.open(auth_url) + authtoken = resp.read() + + if not authtoken or not self.authtoken_re.match(authtoken): + self.fail("Unable to retrive an authtoken for user:%s password: %s at %s got %s" % (username, password, auth_url, str(authtoken))) + return authtoken + + def test_get_or_create_user_already_exists(self): + backend = DekiUserBackend() + deki_user = DekiUser(13, 'hobo', 'Hobo McKee', 'almost@home.me', 'http://www.audienceoftwo.com/pics/upload/v1i6hobo.jpg') + + user = backend.get_or_create_user(deki_user) + self.assertEquals(user.username, 'hobo') + self.assertEquals(2, user.id) + self.assertEquals(3, user.get_profile().id) + self.assertEquals(13, user.get_profile().deki_user_id) diff --git a/apps/dekicompat/views.py b/apps/dekicompat/views.py new file mode 100644 index 00000000000..911d70af789 --- /dev/null +++ b/apps/dekicompat/views.py @@ -0,0 +1,17 @@ +from django.http import HttpResponseRedirect + +from django.utils.http import urlencode + +import commonware + +log = commonware.log.getLogger('mdn.dekicompat') + +def logout(request): + """ Clear Django user from session and let Dekiwiki do it's thang... + returntotitle is a Deki idiom, do not change! + """ + request.session.flush() + rtt = request.GET.get('returntotitle', "/") + params = {'title': "Special:Userlogout", + 'returntotitle': rtt} + return HttpResponseRedirect("index.php?%s" % urlencode(params)) diff --git a/apps/demos/__init__.py b/apps/demos/__init__.py new file mode 100644 index 00000000000..5f20e7fa0ad --- /dev/null +++ b/apps/demos/__init__.py @@ -0,0 +1,296 @@ +from django.conf import settings +from django.core.files.base import ContentFile +from django.utils.translation import ugettext_lazy as _ + +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO + +try: + from PIL import Image +except ImportError: + import Image + + +def scale_image(img_upload, img_max_size): + """Crop and scale an image file.""" + try: + img = Image.open(img_upload) + except IOError: + return None + + src_width, src_height = img.size + src_ratio = float(src_width) / float(src_height) + dst_width, dst_height = img_max_size + dst_ratio = float(dst_width) / float(dst_height) + + if dst_ratio < src_ratio: + crop_height = src_height + crop_width = crop_height * dst_ratio + x_offset = int(float(src_width - crop_width) / 2) + y_offset = 0 + else: + crop_width = src_width + crop_height = crop_width / dst_ratio + x_offset = 0 + y_offset = int(float(src_height - crop_height) / 3) + + img = img.crop((x_offset, y_offset, + x_offset+int(crop_width), y_offset+int(crop_height))) + img = img.resize((dst_width, dst_height), Image.ANTIALIAS) + + if img.mode != "RGB": + img = img.convert("RGB") + new_img = StringIO() + img.save(new_img, "JPEG") + img_data = new_img.getvalue() + + return ContentFile(img_data) + + +# HACK: For easier L10N, define tag descriptions in code instead of as a DB model +TAG_DESCRIPTIONS = dict( (x['tag_name'], x) for x in getattr(settings, 'TAG_DESCRIPTIONS', ( + { + "tag_name": "audio", + "title": _("Audio"), + "description": _("Mozilla's Audio Data API extends the current HTML5 API and allows web developers to read and write raw audio data."), + "learn_more": ( + (_('MDN Documentation'), _('https://developer.mozilla.org/en/Introducing_the_Audio_API_Extension')), + (_('Wikipedia Article'), _('http://en.wikipedia.org/wiki/HTML5_audio')), + (_('W3C Spec'), _('http://www.w3.org/TR/html5/video.html#audio')), + ), + }, + { + "tag_name": "canvas", + "title": _("Canvas"), + "description": _("The HTML5 canvas element allows you to display scriptable renderings of 2D shapes and bitmap images."), + "learn_more": ( + (_('MDN Documentation'), _('https://developer.mozilla.org/en/HTML/Canvas')), + (_('Wikipedia Article'), _('http://en.wikipedia.org/wiki/Canvas_element')), + (_('W3C Spec'), _('http://www.w3.org/TR/html5/the-canvas-element.html')), + ), + }, + { + "tag_name": "css3", + "title": _("CSS3"), + "description": _("Cascading Style Sheets level 3 (CSS3) provide serveral new features and properties to enhance the formatting and look of documents written in different kinds of markup languages like HTML or XML."), + "learn_more": ( + (_('MDN Documentation'), _('https://developer.mozilla.org/en/CSS')), + (_('Wikipedia Article'), _('http://en.wikipedia.org/wiki/Cascading_Style_Sheets')), + (_('W3C Spec'), _('http://www.w3.org/TR/css3-roadmap/')), + ), + }, + { + "tag_name": "device", + "title": _("Device"), + "description": _("Media queries and orientation events let authors adjust their layout on hand-held devices such as mobile phones."), + "learn_more": ( + (_('MDN Documentation'), _('https://developer.mozilla.org/en/Detecting_device_orientation')), + (_('W3C Spec'), _('http://www.w3.org/TR/css3-mediaqueries/')), + ), + }, + { + "tag_name": "files", + "title": _("Files"), + "description": _("The File API allows web developers to use file objects in web applications, as well as selecting and accessing their data."), + "learn_more": ( + (_('MDN Documentation'), _('https://developer.mozilla.org/en/using_files_from_web_applications')), + (_('W3C Spec'), _('http://www.w3.org/TR/FileAPI/')), + ), + }, + { + "tag_name": "fonts", + "title": _("Fonts & Type"), + "description": _("The CSS3-Font specification contains enhanced features for fonts and typography like embedding own fonts via @font-face or controlling OpenType font features directly via CSS."), + "learn_more": ( + (_('MDN Documentation'), _('https://developer.mozilla.org/en/css/@font-face')), + (_('Wikipedia Article'), _('http://en.wikipedia.org/wiki/Web_typography')), + (_('W3C Spec'), _('http://www.w3.org/TR/css3-fonts/')), + ), + }, + { + "tag_name": "forms", + "title": _("Forms"), + "description": _("Form elements and attributes in HTML5 provide a greater degree of semantic mark-up than HTML4 and remove a great deal of the need for tedious scripting and styling that was required in HTML4."), + "learn_more": ( + (_('MDN Documentation'), _('https://developer.mozilla.org/en/HTML/HTML5/Forms_in_HTML5')), + (_('Wikipedia Article'), _('http://en.wikipedia.org/wiki/HTML_forms')), + (_('W3C Spec'), _('http://www.w3.org/TR/html5/forms.html')), + ), + }, + { + "tag_name": "geolocation", + "title": _("Geolocation"), + "description": _("The Geolocation API allows web applications to access the user's geographical location."), + "learn_more": ( + (_('MDN Documentation'), _('https://developer.mozilla.org/En/Using_geolocation')), + (_('Wikipedia Article'), _('http://en.wikipedia.org/wiki/W3C_Geolocation_API')), + (_('W3C Spec'), _('http://dev.w3.org/geo/api/spec-source.html')), + ), + }, + { + "tag_name": "javascript", + "title": _("JavaScript"), + "description": _("JavaScript is a lightweight, object-oriented programming language, commonly used for scripting interactive behavior on web pages and in web applications."), + "learn_more": ( + (_('MDN Documentation'), _('https://developer.mozilla.org/en/javascript')), + (_('Wikipedia Article'), _('http://en.wikipedia.org/wiki/JavaScript')), + (_('ECMA Spec'), _('http://www.ecma-international.org/publications/standards/Ecma-262.htm')), + ), + }, + { + "tag_name": "html5", + "title": _("HTML5"), + "description": _("HTML5 is the newest version of the HTML standard, currently under development."), + "learn_more": ( + (_('MDN Documentation'), _('https://developer.mozilla.org/en/HTML/HTML5')), + (_('Wikipedia Article'), _('http://en.wikipedia.org/wiki/Html5')), + (_('W3C Spec'), _('http://dev.w3.org/html5/spec/Overview.html')), + ), + }, + { + "tag_name": "indexeddb", + "title": _("IndexedDB"), + "description": _("IndexedDB is an API for client-side storage of significant amounts of structured data and for high performance searches on this data using indexes. "), + "learn_more": ( + (_('MDN Documentation'), _('https://developer.mozilla.org/en/IndexedDB')), + (_('Wikipedia Article'), _('http://en.wikipedia.org/wiki/IndexedDB')), + (_('W3C Spec'), _('http://www.w3.org/TR/IndexedDB/')), + ), + }, + { + "tag_name": "dragndrop", + "title": _("Drag and Drop"), + "description": _("Drag and Drop features allow the user to move elements on the screen using the mouse pointer."), + "learn_more": ( + (_('MDN Documentation'), _('https://developer.mozilla.org/en/DragDrop/Drag_and_Drop')), + (_('Wikipedia Article'), _('http://en.wikipedia.org/wiki/Drag-and-drop')), + (_('W3C Spec'), _('http://www.w3.org/TR/html5/dnd.html')), + ), + }, + { + "tag_name": "mobile", + "title": _("Mobile"), + "description": _("Firefox Mobile brings the true Web experience to mobile phones and other non-PC devices."), + "learn_more": ( + (_('MDN Documentation'), _('https://developer.mozilla.org/En/Mobile')), + (_('Wikipedia Article'), _('http://en.wikipedia.org/wiki/Mobile_web')), + (_('W3C Spec'), _('http://www.w3.org/Mobile/')), + ), + }, + { + "tag_name": "offlinesupport", + "title": _("Offline Support"), + "description": _("Offline caching of web applications' resources using the application cache and local storage."), + "learn_more": ( + (_('MDN Documentation'), _('https://developer.mozilla.org/en/dom/storage#localStorage')), + (_('Wikipedia Article'), _('http://en.wikipedia.org/wiki/Web_Storage')), + (_('W3C Spec'), _('http://dev.w3.org/html5/webstorage/')), + ), + }, + { + "tag_name": "svg", + "title": _("SVG"), + "description": _("Scalable Vector Graphics (SVG) is an XML based language for describing two-dimensional vector graphics."), + "learn_more": ( + (_('MDN Documentation'), _('https://developer.mozilla.org/en/SVG')), + (_('Wikipedia Article'), _('http://en.wikipedia.org/wiki/Scalable_Vector_Graphics')), + (_('W3C Spec'), _('http://www.w3.org/TR/SVG11/')), + ), + }, + { + "tag_name": "video", + "title": _("Video"), + "description": _("The HTML5 video element provides integrated support for playing video media without requiring plug-ins."), + "learn_more": ( + (_('MDN Documentation'), _('https://developer.mozilla.org/En/Using_audio_and_video_in_Firefox')), + (_('Wikipedia Article'), _('http://en.wikipedia.org/wiki/HTML5_video')), + (_('W3C Spec'), _('http://www.w3.org/TR/html5/video.html')), + ), + }, + { + "tag_name": "webgl", + "title": _("WebGL"), + "description": _("In the context of the HTML canvas element WebGL provides an API for 3D graphics in the browser."), + "learn_more": ( + (_('MDN Documentation'), _('https://developer.mozilla.org/en/WebGL')), + (_('Wikipedia Article'), _('http://en.wikipedia.org/wiki/WebGL')), + (_('Khronos Spec'), _('http://www.khronos.org/webgl/')), + ), + }, + { + "tag_name": "websockets", + "title": _("WebSockets"), + "description": _("WebSockets is a technology that makes it possible to open an interactive communication session between the user's browser and a server."), + "learn_more": ( + (_('MDN Documentation'), _('https://developer.mozilla.org/en/WebSockets')), + (_('Wikipedia Article'), _('http://en.wikipedia.org/wiki/Web_Sockets')), + (_('W3C Spec'), _('http://dev.w3.org/html5/websockets/')), + ), + }, + { + "tag_name": "webworkers", + "title": _("Web Workers"), + "description": _("Web Workers provide a simple means for web content to run scripts in background threads."), + "learn_more": ( + (_('MDN Documentation'), _('https://developer.mozilla.org/En/Using_web_workers')), + (_('Wikipedia Article'), _('http://en.wikipedia.org/wiki/Web_Workers')), + (_('W3C Spec'), _('http://www.w3.org/TR/workers/')), + ), + }, + { + "tag_name": "xhr", + "title": _("XMLHttpRequest"), + "description": _("XMLHttpRequest (XHR) is used to send HTTP requests directly to a webserver and load the response data directly back into the script."), + "learn_more": ( + (_('MDN Documentation'), _('https://developer.mozilla.org/En/XMLHttpRequest/Using_XMLHttpRequest')), + (_('Wikipedia Article'), _('http://en.wikipedia.org/wiki/XMLHttpRequest')), + (_('W3C Spec'), _('http://www.w3.org/TR/XMLHttpRequest/')), + ), + }, + { + "tag_name": "multitouch", + "title": _("Multi-touch"), + "description": _("Track the movement of the user's finger on a touch screen, monitoring the raw touch events generated by the system."), + "learn_more": ( + (_('MDN Documentation'), _('https://developer.mozilla.org/en/DOM/Touch_events')), + (_('Wikipedia Article'), _('http://en.wikipedia.org/wiki/Multi-touch')), + (_('W3C Spec'), _('http://www.w3.org/2010/webevents/charter/')), + ), + }, +))) + +# HACK: For easier L10N, define license in code instead of as a DB model +DEMO_LICENSES = dict( (x['name'], x) for x in getattr(settings, 'DEMO_LICENSES', ( + { + 'name': "mpl", + 'title': _("MPL/GPL/LGPL"), + 'link': _('http://www.mozilla.org/MPL/'), + 'icon': '', + }, + { + 'name': "gpl", + 'title': _("GPL"), + 'link': _('http://www.opensource.org/licenses/gpl-license.php'), + 'icon': '', + }, + { + 'name': "bsd", + 'title': _("BSD"), + 'link': _('http://www.opensource.org/licenses/bsd-license.php'), + 'icon': '', + }, + { + 'name': "apache", + 'title': _("Apache"), + 'link': _('http://www.apache.org/licenses/'), + 'icon': '', + }, + { + 'name': "publicdomain", + 'title': _("Public Domain (where applicable by law)"), + 'link': _('http://creativecommons.org/publicdomain/zero/1.0/'), + 'icon': '', + }, +))) diff --git a/apps/demos/admin.py b/apps/demos/admin.py new file mode 100644 index 00000000000..b022bf6d343 --- /dev/null +++ b/apps/demos/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from .models import Submission + + +class SubmissionAdmin(admin.ModelAdmin): + list_display = ( 'title', 'creator', 'featured', 'hidden', 'tags', 'modified', ) + +admin.site.register(Submission, SubmissionAdmin) + diff --git a/apps/demos/feeds.py b/apps/demos/feeds.py new file mode 100644 index 00000000000..77a827e348f --- /dev/null +++ b/apps/demos/feeds.py @@ -0,0 +1,222 @@ +"""Feeds for submissions""" +import datetime +import validate_jsonp + +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.utils.translation import ugettext as _ + +from django.contrib.auth.models import User +from django.conf import settings + +from devmo.urlresolvers import reverse + +from tagging.utils import parse_tag_input +from tagging.models import Tag, TaggedItem +from .models import Submission, TAG_DESCRIPTIONS + + +MAX_FEED_ITEMS = getattr(settings, 'MAX_FEED_ITEMS', 15) + + +class SubmissionJSONFeedGenerator(SyndicationFeed): + """JSON feed generator for Submissions + TODO: Someday maybe make this into a JSON Activity Stream?""" + mime_type = 'application/json' + + def _encode_complex(self, obj): + if isinstance(obj, datetime.datetime): + return obj.isoformat() + + 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 + + items_out = [] + for item in self.items: + + # Include some of the simple elements from the preprocessed feed item + item_out = dict( (x, item[x]) for x in ( + 'link', 'title', 'pubdate', 'author_name', 'author_link', + )) + + # Linkify the tags used in the feed item + item_out['categories'] = dict( + (x, request.build_absolute_uri(reverse('demos_tag', kwargs={'tag':x}))) + for x in item['categories'] + ) + + # Include a few more, raw from the submission object itself. + item_out.update( (x, str(getattr(item['obj'], x))) for x in ( + 'summary', 'description', + )) + + item_out['featured'] = item['obj'].featured + + # Include screenshot as an absolute URL. + item_out['screenshot'] = request.build_absolute_uri( + item['obj'].screenshot_1.url) + + # HACK: This .replace() should probably be done in the model + item_out['thumbnail'] = request.build_absolute_uri( + item['obj'].screenshot_1.url).replace('screenshot', 'screenshot_thumb') + + #TODO: What else might be useful in a JSON feed of demo submissions? + # Comment, like, view counts may change too much for caching to be useful + + items_out.append(item_out) + + data = items_out + + if callback: outfile.write('%s(' % callback) + outfile.write(json.dumps(data, default=self._encode_complex)) + if callback: outfile.write(')') + + +class SubmissionsFeed(Feed): + title = _('MDN demos') + subtitle = _('Demos submitted by MDN users') + link = '/' + + def __call__(self, request, *args, **kwargs): + self.request = request + return super(SubmissionsFeed, self).__call__(request, *args, **kwargs) + + def feed_extra_kwargs(self, obj): + return { 'request': self.request, } + + def item_extra_kwargs(self, obj): + return { 'obj': obj, } + + def get_object(self, request, format): + if format == 'json': + self.feed_type = SubmissionJSONFeedGenerator + elif format == 'rss': + self.feed_type = Rss201rev2Feed + else: + self.feed_type = Atom1Feed + + def item_pubdate(self, submission): + return submission.modified + + def item_title(self, submission): + return submission.title + + def item_description(self, submission): + return jingo.render_to_string(self.request, + 'demos/feed_item_description.html', dict( + request=self.request, submission=submission + ) + ) + + def item_author_name(self, submission): + return '%s' % submission.creator + + def item_author_link(self, submission): + return self.request.build_absolute_uri( + reverse('demos.views.profile_detail', + args=(submission.creator.username,))) + + def item_link(self, submission): + return self.request.build_absolute_uri( + reverse('demos.views.detail', + args=(submission.slug,))) + + def item_categories(self, submission): + return parse_tag_input(submission.tags) + + def item_copyright(self, submission): + # TODO: Translate license name to something meaningful in the feed + return submission.license_name + + def item_enclosure_url(self, submission): + return self.request.build_absolute_uri(submission.demo_package.url) + + def item_enclosure_length(self, submission): + return submission.demo_package.size + + def item_enclosure_mime_type(self, submission): + return 'application/zip' + + +class RecentSubmissionsFeed(SubmissionsFeed): + + title = _('MDN recent demos') + subtitle = _('Demos recently submitted to MDN') + + def items(self): + submissions = Submission.objects\ + .exclude(hidden=True)\ + .order_by('-modified').all()[:MAX_FEED_ITEMS] + return submissions + + +class FeaturedSubmissionsFeed(SubmissionsFeed): + + title = _('MDN featured demos') + subtitle = _('Demos featured on MDN') + + def items(self): + submissions = Submission.objects.filter(featured=True)\ + .exclude(hidden=True)\ + .order_by('-modified').all()[:MAX_FEED_ITEMS] + return submissions + + +class TagSubmissionsFeed(SubmissionsFeed): + + def get_object(self, request, format, tag): + super(TagSubmissionsFeed, self).get_object(request, format) + if tag in TAG_DESCRIPTIONS: + self.title = _('MDN demos tagged %s') % TAG_DESCRIPTIONS[tag]['title'] + self.subtitle = TAG_DESCRIPTIONS[tag]['description'] + else: + self.title = _('MDN demos tagged "%s"') % tag + self.subtitle = None + return tag + + def items(self, tag): + qs = Submission.objects.exclude(hidden=True) + submissions = TaggedItem.objects.get_by_model(qs, [tag]) + return submissions.order_by('-modified').all()[:MAX_FEED_ITEMS] + + +class ProfileSubmissionsFeed(SubmissionsFeed): + + def get_object(self, request, format, username): + super(ProfileSubmissionsFeed, self).get_object(request, format) + user = get_object_or_404(User, username=username) + self.title = _("%s's MDN demos") % user.username + return user + + def items(self, user): + submissions = Submission.objects.filter(creator=user)\ + .exclude(hidden=True)\ + .order_by('-modified').all()[:MAX_FEED_ITEMS] + return submissions + + +class SearchSubmissionsFeed(SubmissionsFeed): + + def get_object(self, request, format): + query_string = request.GET.get('q', '') + super(SearchSubmissionsFeed, self).get_object(request, format) + self.title = _('MDN demo search for "%s"') % query_string + self.subtitle = _('Search results for demo submissions matching "%s"') % query_string + return query_string + + def items(self, query_string): + submissions = Submission.objects.search(query_string, 'created')\ + .exclude(hidden=True)\ + .order_by('-modified').all()[:MAX_FEED_ITEMS] + return submissions diff --git a/apps/demos/forms.py b/apps/demos/forms.py new file mode 100644 index 00000000000..62a4fb4b8b8 --- /dev/null +++ b/apps/demos/forms.py @@ -0,0 +1,153 @@ +from hashlib import md5 + +import zipfile +import tarfile + +from django import forms + +from django.utils.encoding import smart_unicode, smart_str + +from django.conf import settings + +from django.utils.translation import ugettext_lazy as _ +from django.contrib.auth.models import User +from django.db import models +from django.core.exceptions import ObjectDoesNotExist +from django.core import validators +from django.core.exceptions import ValidationError +#from uni_form.helpers import FormHelper, Submit, Reset +from django.core.files.base import ContentFile +from django.core.files.uploadedfile import InMemoryUploadedFile + +from . import scale_image +from .models import Submission, TAG_DESCRIPTIONS + +from captcha.fields import ReCaptchaField + +import django.forms.fields +from django.forms.widgets import CheckboxSelectMultiple + +import tagging.forms +from tagging.utils import parse_tag_input + +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO + +try: + from PIL import Image +except ImportError: + import Image + + +SCREENSHOT_MAXW = getattr(settings, 'DEMO_SCREENSHOT_MAX_WIDTH', 480) +SCREENSHOT_MAXH = getattr(settings, 'DEMO_SCREENSHOT_MAX_HEIGHT', 360) + + +class MyModelForm(forms.ModelForm): + def as_ul(self): + "Returns this form rendered as HTML
    • s -- excluding the
        ." + return self._html_output( + normal_row = u'%(label)s %(field)s%(help_text)s%(errors)s
      • ', + error_row = u'
      • %s
      • ', + row_ender = '', + help_text_html = u'

        %s

        ', + errors_on_separate_row = False) + + +class MyForm(forms.Form): + def as_ul(self): + "Returns this form rendered as HTML
      • s -- excluding the
          ." + return self._html_output( + normal_row = u'%(label)s %(field)s%(help_text)s%(errors)s
        • ', + error_row = u'
        • %s
        • ', + row_ender = '', + help_text_html = u'

          %s

          ', + errors_on_separate_row = False) + + +class ConstrainedTagWidget(CheckboxSelectMultiple): + """Checkbox select widget for tag descriptions""" + + def __init__(self, attrs=None, choices=()): + super(ConstrainedTagWidget, self).__init__(attrs) + + if not choices: + choices = ( (x['tag_name'], x['title']) + for x in TAG_DESCRIPTIONS.values() ) + + self.choices = list(choices) + + def render(self, name, value, attrs=None, choices=()): + if not isinstance(value, (list, tuple)): + value = parse_tag_input(value) + return super(ConstrainedTagWidget, self).render( + name, value, attrs, choices) + + +class ConstrainedTagFormField(tagging.forms.TagField): + """Tag field that constrains its input to the set of available + TAG_DESCRIPTION entries""" + + widget = ConstrainedTagWidget + + def clean(self, value): + # Concatenate the checkboxes into a string usable by the superclass, + # but skip superclass' clean() because we'll assume that TAG_DESCRIPTION + # tag names don't exceed the intended MAX_TAG_LENGTH + if not isinstance(value, (list, tuple)): + return value + else: + return ','.join('"%s"' % x for x in value) + + +class SubmissionEditForm(MyModelForm): + """Form accepting demo submissions""" + + class Meta: + model = Submission + widgets = { + 'navbar_optout': forms.Select + } + fields = ( + 'title', 'summary', 'description', 'tags', + 'screenshot_1', 'screenshot_2', 'screenshot_3', + 'screenshot_4', 'screenshot_5', + 'video_url', 'navbar_optout', + 'demo_package', 'source_code_url', 'license_name', + ) + + def clean(self): + cleaned_data = super(SubmissionEditForm, self).clean() + + if 'demo_package' in self.files: + try: + demo_package = self.files['demo_package'] + Submission.validate_demo_zipfile(demo_package) + except ValidationError, e: + self._errors['demo_package'] = self.error_class(e.messages) + + # TODO: Should this be moved to model class? + for idx in range(1, 6): + name = 'screenshot_%s' % idx + if name in self.files: + scaled_file = scale_image(self.files[name].file, + (SCREENSHOT_MAXW, SCREENSHOT_MAXH)) + if not scaled_file: + self._errors[name] = self.error_class([_('Cannot process image')]) + else: + self.files[name].file = scaled_file + + return cleaned_data + + +class SubmissionNewForm(SubmissionEditForm): + + class Meta(SubmissionEditForm.Meta): + fields = SubmissionEditForm.Meta.fields + ( 'captcha', 'accept_terms', ) + + captcha = ReCaptchaField(label=_("Show us you're human")) + accept_terms = forms.BooleanField(initial=False, required=True) + + diff --git a/apps/demos/helpers.py b/apps/demos/helpers.py new file mode 100644 index 00000000000..a27af9814c4 --- /dev/null +++ b/apps/demos/helpers.py @@ -0,0 +1,380 @@ +import datetime +import urllib +import logging +import functools +import hashlib + +from django.core.cache import cache +#from django.utils.translation import ungettext, ugettext +from tower import ugettext_lazy as _lazy, ungettext + +from django.conf import settings + +import jingo +import jinja2 +from jinja2 import evalcontextfilter, Markup, escape +from jingo import register, env +from tower import ugettext as _ +from tower import ugettext, ungettext +from django.core import urlresolvers + +from babel import localedata +from babel.dates import format_date, format_time, format_datetime +from babel.numbers import format_decimal + +from pytz import timezone +from django.utils.tzinfo import LocalTimezone + +from django.core.urlresolvers import reverse as django_reverse +from devmo.urlresolvers import reverse + +from tagging.models import Tag, TaggedItem +from tagging.utils import LINEAR, LOGARITHMIC + +from .models import Submission, TAG_DESCRIPTIONS, DEMO_LICENSES + +from threadedcomments.models import ThreadedComment, FreeThreadedComment +from threadedcomments.forms import ThreadedCommentForm, FreeThreadedCommentForm +from threadedcomments.templatetags import threadedcommentstags +import threadedcomments.views + +# Monkeypatch threadedcomments URL reverse() to use devmo's +from devmo.urlresolvers import reverse +threadedcommentstags.reverse = reverse + + +TEMPLATE_INCLUDE_CACHE_EXPIRES = getattr(settings, 'TEMPLATE_INCLUDE_CACHE_EXPIRES', 300) + + +def new_context(context, **kw): + c = dict(context.items()) + c.update(kw) + return c + +# TODO:liberate ? +def register_cached_inclusion_tag(template, key_fn=None, expires=TEMPLATE_INCLUDE_CACHE_EXPIRES): + """Decorator for inclusion tags with output caching. + + Accepts a string or function to generate a cache key based on the incoming + parameters, along with an expiration time configurable as + INCLUDE_CACHE_EXPIRES or an explicit parameter""" + + if key_fn is None: + key_fn = template + + def decorator(f): + @functools.wraps(f) + def wrapper(*args, **kw): + + if type(key_fn) is str: + cache_key = key_fn + else: + cache_key = key_fn(*args, **kw) + + out = cache.get(cache_key) + if out is None: + context = f(*args, **kw) + t = jingo.env.get_template(template).render(context) + out = jinja2.Markup(t) + cache.set(cache_key, out, expires) + return out + + return register.function(wrapper) + return decorator + +def submission_key(prefix): + """Produce a cache key function with a prefix, which generates the rest of + the key based on a submission ID and last-modified timestamp.""" + def k(*args, **kw): + submission = args[0] + return 'submission:%s:%s:%s' % ( prefix, submission.id, submission.modified ) + return k + +# TOOO: All of these inclusion tags could probably be generated & registered +# from a dict of function names and inclusion tag args, since the method bodies +# are all identical. Might be astronaut architecture, though. + +@register.inclusion_tag('demos/elements/submission_creator.html') +def submission_creator(submission): return locals() + +@register.inclusion_tag('demos/elements/profile_link.html') +def profile_link(user, show_gravatar=False, gravatar_size=48): return locals() + +@register.inclusion_tag('demos/elements/submission_thumb.html') +def submission_thumb(submission,extra_class=None): return locals() + +@register.inclusion_tag('demos/elements/submission_listing.html') +def submission_listing(request, submission_list, is_paginated, paginator, page_obj, feed_title, feed_url): + return locals() + +@register.inclusion_tag('demos/elements/tags_list.html') +def tags_list(): return locals() + +# Not cached, because it's small and changes based on current search query string +@register.inclusion_tag('demos/elements/search_form.html') +@jinja2.contextfunction +def search_form(context): + return new_context(**locals()) + +# TODO:liberate +@register.inclusion_tag('demos/elements/gravatar.html') +def gravatar(email, size=72, default=None): + ns = { + 's': str(size), + } + if default: + ns['default'] = default + url = "http://www.gravatar.com/avatar/%s.jpg?%s" % ( + hashlib.md5(email).hexdigest(), + urllib.urlencode(ns) + ) + return {'gravatar': {'url': url, 'size': size}} + +@register.function +def urlencode(args): + """URL encode a query string from a given dict""" + return urllib.urlencode(args) + +bitly_api = None +def _get_bitly_api(): + """Get an instance of the bit.ly API class""" + global bitly_api + if bitly_api is None: + import bitly + login = getattr(settings, 'BITLY_USERNAME', '') + apikey = getattr(settings, 'BITLY_API_KEY', '') + bitly_api = bitly.Api(login, apikey) + return bitly_api + +@register.filter +def bitly_shorten(url): + """Attempt to shorten a given URL through bit.ly / mzl.la""" + try: + # TODO:caching + return _get_bitly_api().shorten(url) + except: + # Just in case the bit.ly service fails or the API key isn't + # configured, fall back to using the original URL. + return url + +@register.function +def license_link(license_name): + if license_name in DEMO_LICENSES: + return DEMO_LICENSES[license_name]['link'] + else: + return license_name + +@register.function +def license_title(license_name): + if license_name in DEMO_LICENSES: + return DEMO_LICENSES[license_name]['title'] + else: + return license_name + +@register.function +def tag_title(tag): + if tag.name in TAG_DESCRIPTIONS: + return TAG_DESCRIPTIONS[tag.name]['title'] + else: + return tag.name + +@register.function +def tag_description(tag): + if tag.name in TAG_DESCRIPTIONS: + return TAG_DESCRIPTIONS[tag.name]['description'] + else: + return tag.name + +@register.function +def tag_learn_more(tag): + if tag.name in TAG_DESCRIPTIONS and 'learn_more' in TAG_DESCRIPTIONS[tag.name]: + return TAG_DESCRIPTIONS[tag.name]['learn_more'] + else: + return [] + +@register.function +def tags_for_object(obj): + tags = Tag.objects.get_for_object(obj) + return tags + +@register.function +def tags_used_for_submissions(): + return Tag.objects.usage_for_model(Submission, counts=True, min_count=1) + +@register.filter +def date_diff(timestamp, to=None): + if not timestamp: + return "" + + compare_with = to or datetime.date.today() + delta = timestamp - compare_with + + if delta.days == 0: return u"today" + elif delta.days == -1: return u"yesterday" + elif delta.days == 1: return u"tomorrow" + + chunks = ( + (365.0, lambda n: ungettext('year', 'years', n)), + (30.0, lambda n: ungettext('month', 'months', n)), + (7.0, lambda n : ungettext('week', 'weeks', n)), + (1.0, lambda n : ungettext('day', 'days', n)), + ) + + for i, (chunk, name) in enumerate(chunks): + if abs(delta.days) >= chunk: + count = abs(round(delta.days / chunk, 0)) + break + + date_str = ugettext('%(number)d %(type)s') % {'number': count, 'type': name(count)} + + if delta.days > 0: return "in " + date_str + else: return date_str + " ago" + +# TODO: Maybe just register the template tag functions in the jingo environment +# directly, rather than building adapter functions? + +@register.function +def get_threaded_comment_flat(content_object, tree_root=0): + return ThreadedComment.public.get_tree(content_object, root=tree_root) + +@register.function +def get_threaded_comment_tree(content_object, tree_root=0): + """Convert the flat list with depth indices into a true tree structure for + recursive template display""" + root = dict( children=[] ) + parent_stack = [ root, ] + + flat = ThreadedComment.public.get_tree(content_object, root=tree_root) + for comment in flat: + c = dict(comment=comment, children=[]) + if comment.depth > len(parent_stack) - 1 and len(parent_stack[-1]['children']): + parent_stack.append(parent_stack[-1]['children'][-1]) + while comment.depth < len(parent_stack) - 1: + parent_stack.pop(-1) + parent_stack[-1]['children'].append(c) + + return root + +@register.inclusion_tag('demos/elements/comments_tree.html') +def comments_tree(request, object, root): return locals() + +@register.function +def get_comment_url(content_object, parent=None): + return threadedcommentstags.get_comment_url(content_object, parent) + +@register.function +def get_threaded_comment_form(): + return ThreadedCommentForm() + +@register.function +def auto_transform_markup(comment): + return threadedcommentstags.auto_transform_markup(comment) + +@register.function +def can_delete_comment(comment, user): + return threadedcomments.views.can_delete_comment(comment, user) + +@register.filter +def timesince(d, now=None): + """Take two datetime objects and return the time between d and now as a + nicely formatted string, e.g. "10 minutes". If d is None or occurs after + now, return ''. + + Units used are years, months, weeks, days, hours, and minutes. Seconds and + microseconds are ignored. Just one unit is displayed. For example, + "2 weeks" and "1 year" are possible outputs, but "2 weeks, 3 days" and "1 + year, 5 months" are not. + + Adapted from django.utils.timesince to have better i18n (not assuming + commas as list separators and including "ago" so order of words isn't + assumed), show only one time unit, and include seconds. + + """ + if d is None: + return u'' + chunks = [ + (60 * 60 * 24 * 365, lambda n: ungettext('%(number)d year ago', + '%(number)d years ago', n)), + (60 * 60 * 24 * 30, lambda n: ungettext('%(number)d month ago', + '%(number)d months ago', n)), + (60 * 60 * 24 * 7, lambda n: ungettext('%(number)d week ago', + '%(number)d weeks ago', n)), + (60 * 60 * 24, lambda n: ungettext('%(number)d day ago', + '%(number)d days ago', n)), + (60 * 60, lambda n: ungettext('%(number)d hour ago', + '%(number)d hours ago', n)), + (60, lambda n: ungettext('%(number)d minute ago', + '%(number)d minutes ago', n)), + (1, lambda n: ungettext('%(number)d second ago', + '%(number)d seconds ago', n))] + if not now: + if d.tzinfo: + now = datetime.datetime.now(LocalTimezone(d)) + else: + now = datetime.datetime.now() + + # Ignore microsecond part of 'd' since we removed it from 'now' + delta = now - (d - datetime.timedelta(0, 0, d.microsecond)) + since = delta.days * 24 * 60 * 60 + delta.seconds + if since <= 0: + # d is in the future compared to now, stop processing. + return u'' + for i, (seconds, name) in enumerate(chunks): + count = since // seconds + if count != 0: + break + return name(count) % {'number': count} + + +def _babel_locale(locale): + """Return the Babel locale code, given a normal one.""" + # Babel uses underscore as separator. + return locale.replace('-', '_') + + +def _contextual_locale(context): + """Return locale from the context, falling back to a default if invalid.""" + locale = context['request'].locale + if not localedata.exists(locale): + locale = settings.LANGUAGE_CODE + return locale + + +@register.function +@jinja2.contextfunction +def datetimeformat(context, value, format='shortdatetime'): + """ + Returns date/time formatted using babel's locale settings. Uses the + timezone from settings.py + """ + if not isinstance(value, datetime.datetime): + # Expecting date value + raise ValueError + + tzinfo = timezone(settings.TIME_ZONE) + tzvalue = tzinfo.localize(value) + locale = _babel_locale(_contextual_locale(context)) + + # If within a day, 24 * 60 * 60 = 86400s + if format == 'shortdatetime': + # Check if the date is today + if value.toordinal() == datetime.date.today().toordinal(): + formatted = _lazy(u'Today at %s') % format_time( + tzvalue, format='short', locale=locale) + else: + formatted = format_datetime(tzvalue, format='short', locale=locale) + elif format == 'longdatetime': + formatted = format_datetime(tzvalue, format='long', locale=locale) + elif format == 'date': + formatted = format_date(tzvalue, locale=locale) + elif format == 'time': + formatted = format_time(tzvalue, locale=locale) + elif format == 'datetime': + formatted = format_datetime(tzvalue, locale=locale) + else: + # Unknown format + raise DateTimeFormatError + + return jinja2.Markup('' % \ + (tzvalue.isoformat(), formatted)) + diff --git a/apps/demos/models.py b/apps/demos/models.py new file mode 100644 index 00000000000..e40e50c9ee1 --- /dev/null +++ b/apps/demos/models.py @@ -0,0 +1,500 @@ +from datetime import datetime +from time import strftime +from os import unlink, makedirs +from os.path import basename, dirname, isfile, isdir +from shutil import rmtree +import re + +import logging + +import zipfile +import tarfile + +from django.conf import settings + +from django.utils.encoding import smart_unicode, smart_str + +from devmo.urlresolvers import reverse + +from django.core.exceptions import ValidationError + +from django.db import models +from django.db.models import Q + +from django.db.models.fields.files import FieldFile, ImageFieldFile +from django.core.files.storage import FileSystemStorage + +from django.utils.translation import ugettext_lazy as _ +from django.template.defaultfilters import slugify +from django.template.loader import render_to_string +from django.template.defaultfilters import slugify, filesizeformat + +import caching.base + +from django.contrib.sites.models import Site +from django.contrib.auth.models import User + +import tagging +import tagging.fields +import tagging.models + +from tagging.utils import parse_tag_input +from tagging.fields import TagField +from tagging.models import Tag + +from threadedcomments.models import ThreadedComment, FreeThreadedComment + +from actioncounters.fields import ActionCounterField + +from embedutils import VideoEmbedURLField + +from . import scale_image +from . import TAG_DESCRIPTIONS, DEMO_LICENSES + +try: + from PIL import Image +except ImportError: + import Image + + +THUMBNAIL_MAXW = getattr(settings, 'DEMO_THUMBNAIL_MAX_WIDTH', 200) +THUMBNAIL_MAXH = getattr(settings, 'DEMO_THUMBNAIL_MAX_HEIGHT', 150) + +DEMO_MAX_ZIP_FILESIZE = getattr(settings, 'DEMO_MAX_ZIP_FILESIZE', 60 * 1024 * 1024) # 60MB +DEMO_MAX_FILESIZE_IN_ZIP = getattr(settings, 'DEMO_MAX_FILESIZE_IN_ZIP', 60 * 1024 * 1024) # 60MB + +# Set up a file system for demo uploads that can be kept separate from the rest +# of /media if necessary. Lots of hackery here to ensure a set of sensible +# defaults are tried. +DEMO_UPLOADS_ROOT = getattr(settings, 'DEMO_UPLOADS_ROOT', + '%s/uploads/demos' % getattr(settings, 'MEDIA_ROOT', 'media')) +DEMO_UPLOADS_URL = getattr(settings, 'DEMO_UPLOADS_URL', + '%s/uploads/demos' % getattr(settings, 'MEDIA_URL', '/media')) +demo_uploads_fs = FileSystemStorage(location=DEMO_UPLOADS_ROOT, base_url=DEMO_UPLOADS_URL) + + +def get_root_for_submission(instance): + """Build a root path for demo submission files""" + c_name = instance.creator.username + return '%(h1)s/%(h2)s/%(username)s/%(slug)s' % dict( + h1=c_name[0], h2=c_name[1], username=c_name, slug=instance.slug,) + +def mk_upload_to(field_fn): + """upload_to builder for file upload fields""" + def upload_to(instance, filename): + return '%(base)s/%(field_fn)s' % dict( + base=get_root_for_submission(instance), field_fn=field_fn) + return upload_to + + +class ConstrainedTagField(tagging.fields.TagField): + """Tag field constrained to described tags""" + + def __init__(self, *args, **kwargs): + if 'max_tags' not in kwargs: + self.max_tags = 5 + else: + self.max_tags = kwargs['max_tags'] + del kwargs['max_tags'] + super(ConstrainedTagField, self).__init__(*args, **kwargs) + + def validate(self, value, instance): + + if not isinstance(value, (list, tuple)): + value = parse_tag_input(value) + + if len(value) > self.max_tags: + raise ValidationError(_('Maximum of %s tags allowed') % + (self.max_tags)) + + for tag_name in value: + if not tag_name in TAG_DESCRIPTIONS: + raise ValidationError( + _('Tag "%s" is not in the set of described tags') % + (tag_name)) + + def formfield(self, **kwargs): + from .forms import ConstrainedTagFormField + defaults = {'form_class': ConstrainedTagFormField} + defaults.update(kwargs) + return super(ConstrainedTagField, self).formfield(**defaults) + + +class OverwritingFieldFile(FieldFile): + """The built-in FieldFile alters the filename when saving, if a file with + that name already exists. This subclass deletes an existing file first so + that an upload will replace it.""" + # TODO:liberate + def save(self, name, content, save=True): + name = self.field.generate_filename(self.instance, name) + self.storage.delete(name) + super(OverwritingFieldFile, self).save(name,content,save) + + +class OverwritingFileField(models.FileField): + # TODO:liberate + """This field causes an uploaded file to replace an existing one on disk.""" + attr_class = OverwritingFieldFile + + def __init__(self, *args, **kwargs): + self.max_upload_size = kwargs.pop("max_upload_size") + super(OverwritingFileField, self).__init__(*args, **kwargs) + + def clean(self, *args, **kwargs): + data = super(OverwritingFileField, self).clean(*args, **kwargs) + + file = data.file + try: + if file._size > self.max_upload_size: + raise ValidationError( + _('Please keep filesize under %s. Current filesize %s') % + (filesizeformat(self.max_upload_size), filesizeformat(file._size)) + ) + except AttributeError: + pass + + return data + + +class OverwritingImageFieldFile(ImageFieldFile): + # TODO:liberate + """The built-in FieldFile alters the filename when saving, if a file with + that name already exists. This subclass deletes an existing file first so + that an upload will replace it.""" + def save(self, name, content, save=True): + name = self.field.generate_filename(self.instance, name) + self.storage.delete(name) + super(OverwritingImageFieldFile, self).save(name,content,save) + + +class OverwritingImageField(models.ImageField): + # TODO:liberate + """This field causes an uploaded file to replace an existing one on disk.""" + attr_class = OverwritingImageFieldFile + + +class SubmissionManager(caching.base.CachingManager): + """Manager for Submission objects""" + + # TODO: Make these search functions into a mixin? + + # See: http://www.julienphalip.com/blog/2008/08/16/adding-search-django-site-snap/ + def _normalize_query(self, query_string, + findterms=re.compile(r'"([^"]+)"|(\S+)').findall, + normspace=re.compile(r'\s{2,}').sub): + ''' Splits the query string in invidual keywords, getting rid of unecessary spaces + and grouping quoted words together. + Example: + + >>> normalize_query(' some random words "with quotes " and spaces') + ['some', 'random', 'words', 'with quotes', 'and', 'spaces'] + + ''' + return [normspace(' ', (t[0] or t[1]).strip()) for t in findterms(query_string)] + + # See: http://www.julienphalip.com/blog/2008/08/16/adding-search-django-site-snap/ + def _get_query(self, query_string, search_fields): + ''' Returns a query, that is a combination of Q objects. That combination + aims to search keywords within a model by testing the given search fields. + + ''' + query = None # Query to search for every search term + terms = self._normalize_query(query_string) + for term in terms: + or_query = None # Query to search for a given term in each field + for field_name in search_fields: + q = Q(**{"%s__icontains" % field_name: term}) + if or_query is None: + or_query = q + else: + or_query = or_query | q + if query is None: + query = or_query + else: + query = query & or_query + return query + + def search(self, query_string, sort): + """Quick and dirty keyword search on submissions""" + # TODO: Someday, replace this with something like Sphinx or another real search engine + strip_qs = query_string.strip() + if not strip_qs: + return self.all_sorted(sort).order_by('-modified') + else: + query = self._get_query(strip_qs, ['title', 'summary', 'description',]) + return self.all_sorted(sort).filter(query).order_by('-modified') + + def all_sorted(self, sort=None): + """Apply to .all() one of the sort orders supported for views""" + queryset = self.all() + if sort == 'launches': + return queryset.order_by('-launches_total') + elif sort == 'likes': + return queryset.order_by('-likes_total') + elif sort == 'upandcoming': + return queryset.order_by('-launches_recent','-likes_recent') + else: + return queryset.order_by('-created') + + +class Submission(caching.base.CachingMixin, models.Model): + """Representation of a demo submission""" + objects = SubmissionManager() + + title = models.CharField( + _("what is your demo's name?"), + max_length=255, blank=False, unique=True) + slug = models.SlugField(_("slug"), + blank=False, unique=True) + summary = models.CharField( + _("describe your demo in one line"), + max_length=255, blank=False) + description = models.TextField( + _("describe your demo in more detail (optional)"), + blank=True) + + featured = models.BooleanField() + hidden = models.BooleanField() + + navbar_optout = models.BooleanField( + _('control how your demo is launched'), + choices=( + (True, _('Disable navigation bar, launch demo in a new window')), + (False, _('Use navigation bar, display demo in + + + + + diff --git a/apps/demos/templates/demos/listing_all.html b/apps/demos/templates/demos/listing_all.html new file mode 100644 index 00000000000..973412d66dd --- /dev/null +++ b/apps/demos/templates/demos/listing_all.html @@ -0,0 +1,50 @@ +{% extends "demos/base.html" %} + +{% block pageid %}demos{% endblock %} +{% block bodyclass %}section-demos{% endblock %} +{% block title %}{{ page_title(_('{subtitle} | Demo Studio') | f(subtitle=_('Browse all demos'))) }}{% endblock %} +{% block extrahead %} + +{% endblock %} + +{% block content %} + + + +
          +
          + + + +
          + {{ submission_listing( + request, submission_list, is_paginated, paginator, page_obj, + _('Subscribe to a feed of all demos'), + url('demos_feed_recent', format='atom') + ) }} +
          + + + +
          +
          +{% endblock %} diff --git a/apps/demos/templates/demos/listing_search.html b/apps/demos/templates/demos/listing_search.html new file mode 100644 index 00000000000..0508bfddc69 --- /dev/null +++ b/apps/demos/templates/demos/listing_search.html @@ -0,0 +1,57 @@ +{% extends "demos/base.html" %} + +{% set query = request.GET.get('q','') %} +{% if not query %} + {% set query = ' ' %} +{% endif %} + +{% block pageid %}demos{% endblock %} +{% block bodyclass %}section-demos{% endblock %} +{% block title %}{{ page_title(_('{subtitle} | Demo Studio') | f(subtitle=_('Search for "{q}"') | f(q=query))) }}{% endblock %} + +{% block extrahead %} + +{% endblock %} + +{% block content %} + + +
          +
          + + + +
          + {{ submission_listing( + request, submission_list, is_paginated, paginator, page_obj, + _('Subscribe to a feed of search results for "{q}"') | f(q=query), + url('demos_feed_search', format='atom') | urlparams(None, q=query) + ) }} +
          {# /#content-main #} + + + +
          +
          {# /#content #} + +{% endblock %} diff --git a/apps/demos/templates/demos/listing_tag.html b/apps/demos/templates/demos/listing_tag.html new file mode 100644 index 00000000000..061664c937f --- /dev/null +++ b/apps/demos/templates/demos/listing_tag.html @@ -0,0 +1,66 @@ +{% extends "demos/base.html" %} + +{% block pageid %}demos{% endblock %} +{% block bodyclass %}section-demos{% endblock %} +{% block title %}{{ page_title(_('{subtitle} | Demo Studio') | f(subtitle=_('{tag_title} Demos') | f(tag_title=tag_title(tag)))) }}{% endblock %} +{% block extrahead %} + +{% endblock %} + +{% block content %} + + + +
          +
          + + + +
          + {{ submission_listing( + request, submission_list, is_paginated, paginator, page_obj, + _('Subscribe to a feed of {tag_title} demos') | f(tag_title=tag_title(tag)), + url('demos_feed_tag', format='atom', tag=tag.name) + ) }} +
          + + + +
          +
          + +{% endblock %} diff --git a/apps/demos/templates/demos/profile_detail.html b/apps/demos/templates/demos/profile_detail.html new file mode 100644 index 00000000000..6407423f380 --- /dev/null +++ b/apps/demos/templates/demos/profile_detail.html @@ -0,0 +1,68 @@ +{% extends "demos/base.html" %} + +{% set display_name = profile_user.first_name | default(profile_user.username, true) %} +{% set username = profile_user.username %} + +{% block pageid %}demos{% endblock %} +{% block bodyclass %}section-demos{% endblock %} +{% block title %}{{ page_title(_('{subtitle} | Demo Studio') | f(subtitle=_('{display_name}') | f(display_name=display_name))) }}{% endblock %} + +{% block extrahead %} + +{% endblock %} + +{% block content %} + + + +
          +
          + + + +
          + {{ submission_listing( + request, submission_list, is_paginated, paginator, page_obj, + _("Subscribe to a feed of {display_name}'s demos") | f(display_name=display_name), + url('demos_feed_profile', format='atom', username=username) + ) }} +
          + + + +
          +
          {# /#content #} + +{% endblock %} diff --git a/apps/demos/templates/demos/submit.html b/apps/demos/templates/demos/submit.html new file mode 100644 index 00000000000..f251e54ef75 --- /dev/null +++ b/apps/demos/templates/demos/submit.html @@ -0,0 +1,260 @@ +{% extends "demos/base.html" %} +{% set obj = submission %} + +{% block pageid %}demos-submit{% endblock %} +{% block bodyclass %}section-demos{% endblock %} +{% block title %} + {% if edit %} + {{ page_title(_('{subtitle} | Demo Studio') | f(subtitle=_("Edit {demo_title}") | f(demo_title=submission.title))) }} + {% else %} + {{ page_title(_('{subtitle} | Demo Studio') | f(subtitle=_('Submit a New Demo'))) }} + {% endif %} +{% endblock %} + +{% macro li_field(form, field_name, classes='', note='', image=False, show_label=True) %} + {% set field = form[field_name] %} +
        • + {% if show_label %} + + {% endif %} + {{ field|safe }} + {% if edit %} + {% if 'file' == field.field.widget.input_type and obj[field_name] %} + {% if image %} +

          + {% else %} + {# {{ obj[field_name] }} #} + {% endif %} + {% endif %} + {% endif %} + {% if note %} +

          {{ note }}

          + {% endif %} + {{ field.errors|safe }} +
        • +{% endmacro %} + +{% block content %} + + +
          +
          + +
          +

          + {% if edit %} + {{_("Edit {demo_title}") | f(demo_title=submission.title)}} + {% else %} + {{_("Submit a New Demo")}} + {% endif %} +

          + + {% if not edit %} + {% trans %} +

          Whether you are creating an amazing new way to experience the Web or just + experimenting with the latest technologies, we invite you to submit your own + demos to share with (or show off to) other web developers.

          +

          Please complete the form below to ensure your demo is submitted to the + Demo Studio successfully.

          + {% endtrans %} + {% endif %} + +
          + {{ csrf() }} +
          + {{_('Describe Your Demo')}} + + {% trans %} +

          Tell us more about your demo, including the name, description and the + technologies used. Please list the browsers you have tested it with.

          + {% endtrans %} + +
            + {{ li_field(form, 'title') }} + {{ li_field(form, 'summary') }} + {{ li_field(form, 'description') }} + +
            + {{_('Select up to five technologies used in your demo.')}} +
              + {{ li_field(form, 'tags', show_label=False) }} +
            +
            + +
          + +
          + +
          + {{_('Show Off Your Demo')}} +
            +
          • +
            + {{_('Provide at least one screenshot of your demo in action')}} +
              + {{ li_field(form, 'screenshot_1', image=True) }} + {{ li_field(form, 'screenshot_2', image=True) }} + {{ li_field(form, 'screenshot_3', image=True) }} + {{ li_field(form, 'screenshot_4', image=True) }} + {{ li_field(form, 'screenshot_5', image=True) }} +
            +
            +

            {{_('JPEG and PNG supported. Minimum size of 480x360.')}}

            +
          • + {{ li_field(form, 'video_url', note=_('We support YouTube and Vimeo')) }} + {{ li_field(form, 'navbar_optout', note=_('If your demo has problems when displayed in an ' +) +DEFAULT_ARGS = { 'width': 480, 'height': 360 } + + +def build_video_embed(url, **kwargs): + for regex, embed_url in EMBED_PATTERNS: + m = regex.match(url) + if m: + args = dict(DEFAULT_ARGS) + args.update(kwargs) + args['url'] = embed_url % m.groupdict() + return EMBED_CODE % args + return None + + +class VideoEmbedURL(object): + """Proxy for access on a VideoEmbedURLField, offers embed_html property""" + + def __init__(self, instance, field, value): + self.instance = instance + self.field = field + self.value = value + + def __unicode__(self): + return self.value + + def _get_embed_html(self): + return Markup(build_video_embed(self.value)) + embed_html = property(_get_embed_html) + + +class VideoEmbedURLDescriptor(object): + """Transforms a plain URL into VideoEmbedURL on field access + see also: django.db.models.fields.files.FileField""" + + def __init__(self, field): + self.field = field + + def __set__(self, instance, value): + instance.__dict__[self.field.name] = value + + def __get__(self, instance=None, owner=None): + if instance is None: + raise AttributeError( + "The '%s' attribute can only be accessed from %s instances." + % (self.field.name, owner.__name__)) + + veurl = instance.__dict__[self.field.name] + + if isinstance(veurl, basestring) or veurl is None: + attr = self.field.attr_class(instance, self.field, veurl) + instance.__dict__[self.field.name] = attr + + elif isinstance(veurl, VideoEmbedURL) and not hasattr(veurl, 'field'): + veurl.instance = instance + veurl.field = self.field + + out = instance.__dict__[self.field.name] + if not out or not out.value: + return None + return out + + +class VideoEmbedURLField(models.URLField): + """URL field with the magical ability to enable media embedding via the + embed_html property""" + + attr_class = VideoEmbedURL + descriptor_class = VideoEmbedURLDescriptor + + def validate(self, value, model_instance): + super(VideoEmbedURLField, self).validate(value, model_instance) + if not build_video_embed(value): + raise ValidationError(_('Not an URL from a supported video service')) + + def get_prep_value(self, field_value): + "Returns field's value prepared for saving into a database." + if field_value is None or field_value.value is None: + return '' + return unicode(field_value) + + def contribute_to_class(self, cls, name): + super(VideoEmbedURLField, self).contribute_to_class(cls, name) + setattr(cls, self.name, self.descriptor_class(self)) diff --git a/lib/utils.py b/lib/utils.py new file mode 100644 index 00000000000..425d1b9fd97 --- /dev/null +++ b/lib/utils.py @@ -0,0 +1,115 @@ +import functools +import HTMLParser +import os +import re +import tempfile + +import commonware.log +import lockfile + + +log = commonware.log.getLogger('basket') +htmlparser = HTMLParser.HTMLParser() + + +def locked(prefix): + """ + Decorator that only allows one instance of the same command to run + at a time. + """ + def decorator(f): + @functools.wraps(f) + def wrapper(self, *args, **kwargs): + name = '_'.join((prefix, f.__name__) + args) + file = os.path.join(tempfile.gettempdir(), name) + lock = lockfile.FileLock(file) + try: + # Try to acquire the lock without blocking. + lock.acquire(0) + except lockfile.LockError: + log.warning('Aborting %s; lock acquisition failed.' % name) + return 0 + else: + # We have the lock, call the function. + try: + return f(self, *args, **kwargs) + finally: + lock.release() + return wrapper + return decorator + + +def cached_property(*args, **kw): + # Handles invocation as a direct decorator or + # with intermediate keyword arguments. + if args: # @cached_property + return CachedProperty(args[0]) + else: # @cached_property(name=..., writable=...) + return lambda f: CachedProperty(f, **kw) + + +class CachedProperty(object): + """A decorator that converts a function into a lazy property. The +function wrapped is called the first time to retrieve the result +and than that calculated result is used the next time you access +the value:: + +class Foo(object): + +@cached_property +def foo(self): +# calculate something important here +return 42 + +Lifted from werkzeug. +""" + + def __init__(self, func, name=None, doc=None, writable=False): + self.func = func + self.writable = writable + self.__name__ = name or func.__name__ + self.__doc__ = doc or func.__doc__ + + def __get__(self, obj, type=None): + if obj is None: + return self + _missing = object() + value = obj.__dict__.get(self.__name__, _missing) + if value is _missing: + value = self.func(obj) + obj.__dict__[self.__name__] = value + return value + + def __set__(self, obj, value): + if not self.writable: + raise TypeError('read only attribute') + obj.__dict__[self.__name__] = value + + +def entity_decode(str): + """Turn HTML entities in a string into unicode.""" + return htmlparser.unescape(str) + +import jingo + +class JingoTemplateLoaderWrapper(): + + def __init__(self, template): + self.template = template + + def render(self, context): + context_dict = {} + for d in context.dicts: + context_dict.update(d) + return self.template.render(context_dict) + +class JingoTemplateLoader(): + """Quick & dirty adaptor to load jinja2 templates via jingo""" + is_usable = True + + def get_template(self, template_name, template_dirs=None): + if not jingo._helpers_loaded: + jingo.load_helpers() + template = jingo.env.get_template(template_name) + return JingoTemplateLoaderWrapper(template) + diff --git a/lib/validate_jsonp.py b/lib/validate_jsonp.py new file mode 100644 index 00000000000..fd9ee2d3194 --- /dev/null +++ b/lib/validate_jsonp.py @@ -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 + +"""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() diff --git a/migrations/mdn/01-initial-feeds.sql b/migrations/mdn/01-initial-feeds.sql new file mode 100644 index 00000000000..9dfb2747376 --- /dev/null +++ b/migrations/mdn/01-initial-feeds.sql @@ -0,0 +1,191 @@ +-- 00 syncdb dump +CREATE TABLE `auth_permission` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `name` varchar(50) NOT NULL, + `content_type_id` integer NOT NULL, + `codename` varchar(100) NOT NULL, + UNIQUE (`content_type_id`, `codename`) +) +; +CREATE TABLE `auth_group_permissions` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `group_id` integer NOT NULL, + `permission_id` integer NOT NULL, + UNIQUE (`group_id`, `permission_id`) +) +; +ALTER TABLE `auth_group_permissions` ADD CONSTRAINT `permission_id_refs_id_5886d21f` FOREIGN KEY (`permission_id`) REFERENCES `auth_permission` (`id`); +CREATE TABLE `auth_group` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `name` varchar(80) NOT NULL UNIQUE +) +; +ALTER TABLE `auth_group_permissions` ADD CONSTRAINT `group_id_refs_id_3cea63fe` FOREIGN KEY (`group_id`) REFERENCES `auth_group` (`id`); +CREATE TABLE `auth_user_user_permissions` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `user_id` integer NOT NULL, + `permission_id` integer NOT NULL, + UNIQUE (`user_id`, `permission_id`) +) +; +ALTER TABLE `auth_user_user_permissions` ADD CONSTRAINT `permission_id_refs_id_67e79cb` FOREIGN KEY (`permission_id`) REFERENCES `auth_permission` (`id`); +CREATE TABLE `auth_user_groups` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `user_id` integer NOT NULL, + `group_id` integer NOT NULL, + UNIQUE (`user_id`, `group_id`) +) +; +ALTER TABLE `auth_user_groups` ADD CONSTRAINT `group_id_refs_id_f116770` FOREIGN KEY (`group_id`) REFERENCES `auth_group` (`id`); +CREATE TABLE `auth_user` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `username` varchar(30) NOT NULL UNIQUE, + `first_name` varchar(30) NOT NULL, + `last_name` varchar(30) NOT NULL, + `email` varchar(75) NOT NULL, + `password` varchar(128) NOT NULL, + `is_staff` bool NOT NULL, + `is_active` bool NOT NULL, + `is_superuser` bool NOT NULL, + `last_login` datetime NOT NULL, + `date_joined` datetime NOT NULL +) +; +ALTER TABLE `auth_user_user_permissions` ADD CONSTRAINT `user_id_refs_id_dfbab7d` FOREIGN KEY (`user_id`) REFERENCES `auth_user` (`id`); +ALTER TABLE `auth_user_groups` ADD CONSTRAINT `user_id_refs_id_7ceef80f` FOREIGN KEY (`user_id`) REFERENCES `auth_user` (`id`); +CREATE TABLE `auth_message` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `user_id` integer NOT NULL, + `message` longtext NOT NULL +) +; +ALTER TABLE `auth_message` ADD CONSTRAINT `user_id_refs_id_650f49a6` FOREIGN KEY (`user_id`) REFERENCES `auth_user` (`id`); +-- The following references should be added but depend on non-existent tables: +-- ALTER TABLE `auth_permission` ADD CONSTRAINT `content_type_id_refs_id_728de91f` FOREIGN KEY (`content_type_id`) REFERENCES `django_content_type` (`id`); +CREATE INDEX `auth_permission_content_type_id_idx` ON `auth_permission` (`content_type_id`); +CREATE INDEX `auth_message_user_id_idx` ON `auth_message` (`user_id`); +CREATE TABLE `django_content_type` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `name` varchar(100) NOT NULL, + `app_label` varchar(100) NOT NULL, + `model` varchar(100) NOT NULL, + UNIQUE (`app_label`, `model`) +) +; +CREATE TABLE `django_session` ( + `session_key` varchar(40) NOT NULL PRIMARY KEY, + `session_data` longtext NOT NULL, + `expire_date` datetime NOT NULL +) +; +CREATE TABLE `django_site` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `domain` varchar(100) NOT NULL, + `name` varchar(50) NOT NULL +) +; +CREATE TABLE `django_admin_log` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `action_time` datetime NOT NULL, + `user_id` integer NOT NULL, + `content_type_id` integer, + `object_id` longtext, + `object_repr` varchar(200) NOT NULL, + `action_flag` smallint UNSIGNED NOT NULL, + `change_message` longtext NOT NULL +) +; +-- The following references should be added but depend on non-existent tables: +-- ALTER TABLE `django_admin_log` ADD CONSTRAINT `content_type_id_refs_id_288599e6` FOREIGN KEY (`content_type_id`) REFERENCES `django_content_type` (`id`); +-- ALTER TABLE `django_admin_log` ADD CONSTRAINT `user_id_refs_id_c8665aa` FOREIGN KEY (`user_id`) REFERENCES `auth_user` (`id`); +CREATE INDEX `django_admin_log_user_id_idx` ON `django_admin_log` (`user_id`); +CREATE INDEX `django_admin_log_content_type_idx` ON `django_admin_log` (`content_type_id`); + +CREATE TABLE `actioncounters_actioncounterunique` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `content_type_id` integer NOT NULL, + `object_pk` varchar(32) NOT NULL, + `name` varchar(64) NOT NULL, + `total` integer NOT NULL, + `ip` varchar(40), + `session_key` varchar(40), + `user_agent` varchar(255), + `user_id` integer, + `modified` datetime NOT NULL +) +; +-- The following references should be added but depend on non-existent tables: +-- ALTER TABLE `actioncounters_actioncounterunique` ADD CONSTRAINT `user_id_refs_id_48ad09db` FOREIGN KEY (`user_id`) REFERENCES `auth_user` (`id`); +-- ALTER TABLE `actioncounters_actioncounterunique` ADD CONSTRAINT `content_type_id_refs_id_5e04cd6f` FOREIGN KEY (`content_type_id`) REFERENCES `django_content_type` (`id`); +CREATE INDEX `actioncounters_actioncounterunique_content_type_ididx` ON `actioncounters_actioncounterunique` (`content_type_id`); +CREATE INDEX `actioncounters_actioncounterunique_name_idx` ON `actioncounters_actioncounterunique` (`name`); +CREATE INDEX `actioncounters_actioncounterunique_ip_idx` ON `actioncounters_actioncounterunique` (`ip`); +CREATE INDEX `actioncounters_actioncounterunique_session_key_idx` ON `actioncounters_actioncounterunique` (`session_key`); +CREATE INDEX `actioncounters_actioncounterunique_user_agent_idx` ON `actioncounters_actioncounterunique` (`user_agent`); +CREATE INDEX `actioncounters_actioncounterunique_user_id_idx` ON `actioncounters_actioncounterunique` (`user_id`); + +CREATE TABLE `user_profiles` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `deki_user_id` integer UNSIGNED NOT NULL, + `homepage` varchar(255) NOT NULL, + `location` varchar(255) NOT NULL, + `user_id` integer +) +; +-- The following references should be added but depend on non-existent tables: +-- ALTER TABLE `user_profiles` ADD CONSTRAINT `user_id_refs_id_69a818e9` FOREIGN KEY (`user_id`) REFERENCES `auth_user` (`id`); +CREATE INDEX `user_profiles_idx` ON `user_profiles` (`user_id`); +CREATE TABLE `feeder_bundle_feeds` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `bundle_id` integer NOT NULL, + `feed_id` integer NOT NULL, + UNIQUE (`bundle_id`, `feed_id`) +) +; +CREATE TABLE `feeder_bundle` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `shortname` varchar(50) NOT NULL UNIQUE +) +; +ALTER TABLE `feeder_bundle_feeds` ADD CONSTRAINT `bundle_id_refs_id_1a46350d` FOREIGN KEY (`bundle_id`) REFERENCES `feeder_bundle` (`id`); +CREATE TABLE `feeder_feed` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `shortname` varchar(50) NOT NULL UNIQUE, + `title` varchar(140) NOT NULL, + `url` varchar(2048) NOT NULL, + `etag` varchar(140) NOT NULL, + `last_modified` datetime NOT NULL, + `enabled` bool NOT NULL, + `disabled_reason` varchar(2048) NOT NULL, + `keep` integer UNSIGNED NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL +) +; +ALTER TABLE `feeder_bundle_feeds` ADD CONSTRAINT `feed_id_refs_id_55f1514b` FOREIGN KEY (`feed_id`) REFERENCES `feeder_feed` (`id`); +CREATE TABLE `feeder_entry` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `feed_id` integer NOT NULL, + `guid` varchar(255) NOT NULL, + `raw` longtext NOT NULL, + `visible` bool NOT NULL, + `last_published` datetime NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + UNIQUE (`feed_id`, `guid`) +) +; +ALTER TABLE `feeder_entry` ADD CONSTRAINT `feed_id_refs_id_3323b4e` FOREIGN KEY (`feed_id`) REFERENCES `feeder_feed` (`id`); +CREATE INDEX `feeder_entry_idx` ON `feeder_entry` (`feed_id`); +-- end 00 + +INSERT INTO `feeder_feed` (`id`, `shortname`, `url`, `enabled`, `keep`, `created`, `updated`) VALUES +(1, 'moz-hacks', 'http://hacks.mozilla.org/feed/', 1, 50, NOW(), NOW()), +(2, 'tw-mozhacks', 'http://twitter.com/statuses/user_timeline/45496942.rss', 1, 50, NOW(), NOW()), +(3, 'tw-mozillaweb', 'http://twitter.com/statuses/user_timeline/38209403.rss', 1, 50, NOW(), NOW()), +(4, 'tw-mozmobile', 'http://twitter.com/statuses/user_timeline/67033966.rss', 1, 50, NOW(), NOW()), +(5, 'tw-mozillaqa', 'http://twitter.com/statuses/user_timeline/24752152.rss', 1, 50, NOW(), NOW()), +(6, 'planet-mobile', 'http://planet.firefox.com/mobile/rss20.xml', 1, 50, NOW(), NOW()), +(7, 'tw-mozamo', 'http://twitter.com/statuses/user_timeline/15383463.rss', 1, 50, NOW(), NOW()), +(8, 'tw-planetmozilla', 'http://twitter.com/statuses/user_timeline/39292665.rss', 1, 50, NOW(), NOW()) +; diff --git a/migrations/mdn/02-feed-bundles.sql b/migrations/mdn/02-feed-bundles.sql new file mode 100644 index 00000000000..b86d6500935 --- /dev/null +++ b/migrations/mdn/02-feed-bundles.sql @@ -0,0 +1,13 @@ +INSERT INTO `feeder_bundle` (`id`, `shortname`) VALUES +(1, 'twitter-web'), +(2, 'twitter-mobile'), +(3, 'twitter-addons'), +(4, 'twitter-apps'); + +INSERT INTO `feeder_bundle_feeds` (`id`, `bundle_id`, `feed_id`) VALUES +(1, 1, 2), +(2, 1, 3), +(3, 1, 5), +(4, 2, 4), +(5, 3, 7), +(6, 4, 8); diff --git a/migrations/mdn/03-updates-bundles.sql b/migrations/mdn/03-updates-bundles.sql new file mode 100644 index 00000000000..07b94adeb8d --- /dev/null +++ b/migrations/mdn/03-updates-bundles.sql @@ -0,0 +1,9 @@ +INSERT INTO `feeder_bundle` (`id`, `shortname`) VALUES +(6, 'updates-addons'), +(5, 'updates-apps'), +(7, 'updates-mobile'), +(8, 'updates-web'); + +INSERT INTO `feeder_bundle_feeds` (`id`, `bundle_id`, `feed_id`) VALUES +(7, 7, 6), +(8, 8, 1); diff --git a/migrations/mdn/04-blog-feeds.sql b/migrations/mdn/04-blog-feeds.sql new file mode 100644 index 00000000000..2271a8be72e --- /dev/null +++ b/migrations/mdn/04-blog-feeds.sql @@ -0,0 +1,9 @@ +INSERT INTO `feeder_feed` (`id`, `shortname`, `url`, `enabled`, `keep`, `created`, `updated`) VALUES +(9, 'moz-hacks-comments', 'http://hacks.mozilla.org/comments/feed/', 1, 50, NOW(), NOW()), +(10, 'amo-blog', 'http://blog.mozilla.com/addons/feed/', 1, 50, NOW(), NOW()), +(11, 'amo-blog-comments', 'http://blog.mozilla.com/addons/comments/feed/', 1, 50, NOW(), NOW()), +(12, 'amo-forums', 'https://forums.addons.mozilla.org/feed.php', 1, 50, NOW(), NOW()) +; + +INSERT INTO `feeder_bundle_feeds` (`id`, `bundle_id`, `feed_id`) VALUES +(9, 6, 10); diff --git a/migrations/mdn/05-about-mozilla-feed.sql b/migrations/mdn/05-about-mozilla-feed.sql new file mode 100644 index 00000000000..b7ecf9214f3 --- /dev/null +++ b/migrations/mdn/05-about-mozilla-feed.sql @@ -0,0 +1,6 @@ +INSERT INTO `feeder_feed` (`id`, `shortname`, `url`, `enabled`, `keep`, `created`, `updated`) VALUES +(13, 'about-mozilla', 'http://blog.mozilla.com/about_mozilla/feed/atom/', 1, 50, NOW(), NOW()) +; + +INSERT INTO `feeder_bundle_feeds` (`id`, `bundle_id`, `feed_id`) VALUES +(10, 5, 13); diff --git a/migrations/mdn/06-dekiwiki-recent-changes-feed.sql b/migrations/mdn/06-dekiwiki-recent-changes-feed.sql new file mode 100644 index 00000000000..f251574de72 --- /dev/null +++ b/migrations/mdn/06-dekiwiki-recent-changes-feed.sql @@ -0,0 +1,3 @@ +INSERT INTO `feeder_feed` (`id`, `shortname`, `url`, `enabled`, `keep`, `created`, `updated`) VALUES +(14, 'mdc-latest', 'https://developer.mozilla.org/@api/deki/site/feed', 1, 50, NOW(), NOW()) +; diff --git a/migrations/mdn/07-apps-becomes-mozilla.sql b/migrations/mdn/07-apps-becomes-mozilla.sql new file mode 100644 index 00000000000..920bd389b2e --- /dev/null +++ b/migrations/mdn/07-apps-becomes-mozilla.sql @@ -0,0 +1,4 @@ +UPDATE feeder_bundle SET shortname = 'twitter-mozilla' +WHERE shortname = 'twitter-apps'; +UPDATE feeder_bundle SET shortname = 'updates-updates' +WHERE shortname = 'updates-apps'; \ No newline at end of file diff --git a/migrations/mdn/08-fix-mozilla-typo.sql b/migrations/mdn/08-fix-mozilla-typo.sql new file mode 100644 index 00000000000..659376c8bf7 --- /dev/null +++ b/migrations/mdn/08-fix-mozilla-typo.sql @@ -0,0 +1 @@ +UPDATE feeder_bundle SET shortname = 'updates-mozilla' WHERE shortname = 'updates-updates'; \ No newline at end of file diff --git a/migrations/mdn/09-add-user-profiles-table.sql b/migrations/mdn/09-add-user-profiles-table.sql new file mode 100644 index 00000000000..ecc6f144e05 --- /dev/null +++ b/migrations/mdn/09-add-user-profiles-table.sql @@ -0,0 +1,14 @@ +-- tables managed via migration instead of syncdb... +DROP TABLE IF EXISTS `user_profiles`; + +BEGIN; +CREATE TABLE `user_profiles` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `deki_user_id` integer UNSIGNED NOT NULL, + `homepage` varchar(255) NOT NULL, + `location` varchar(255) NOT NULL, + `user_id` integer +) +; +ALTER TABLE `user_profiles` ADD CONSTRAINT `user_id_refs_id` FOREIGN KEY (`user_id`) REFERENCES `auth_user` (`id`); +COMMIT; diff --git a/migrations/mdn/10-demo-room-initial.sql b/migrations/mdn/10-demo-room-initial.sql new file mode 100644 index 00000000000..241d82d6e99 --- /dev/null +++ b/migrations/mdn/10-demo-room-initial.sql @@ -0,0 +1,148 @@ +-- +-- Initial tables and data for Demo Gallery +-- + +-- +-- Table structure for table `actioncounters_actionhit` +-- + +DROP TABLE IF EXISTS `actioncounters_actionhit`; +CREATE TABLE `actioncounters_actionhit` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `counter_id` int(11) NOT NULL, + `total` int(11) NOT NULL, + `ip` varchar(40) DEFAULT NULL, + `session_key` varchar(40) DEFAULT NULL, + `user_agent` varchar(255) DEFAULT NULL, + `user_id` int(11) DEFAULT NULL, + `created` datetime NOT NULL, + PRIMARY KEY (`id`), +-- TODO re-enable UNIQUE KEY `ip` (`ip`,`session_key`,`user_agent`,`user_id`), + KEY `actioncounters_actionhit_7952d08b` (`counter_id`), + KEY `actioncounters_actionhit_fbfc09f1` (`user_id`) +); + +-- +-- Table structure for table `contentflagging_contentflag` +-- + +DROP TABLE IF EXISTS `contentflagging_contentflag`; +CREATE TABLE `contentflagging_contentflag` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `flag_status` varchar(16) NOT NULL, + `flag_type` varchar(64) NOT NULL, + `explanation` longtext NOT NULL, + `content_type_id` int(11) NOT NULL, + `object_pk` varchar(32) NOT NULL, + `ip` varchar(40) DEFAULT NULL, + `session_key` varchar(40) DEFAULT NULL, + `user_agent` varchar(255) DEFAULT NULL, + `user_id` int(11) DEFAULT NULL, + `created` datetime NOT NULL, + `modified` datetime NOT NULL, + PRIMARY KEY (`id`), +-- TODO re-enable UNIQUE KEY `content_type_id` (`content_type_id`,`object_pk`,`ip`,`session_key`,`user_agent`,`user_id`), + KEY `contentflagging_contentflag_68c2f437` (`flag_type`), + KEY `contentflagging_contentflag_e4470c6e` (`content_type_id`), + KEY `contentflagging_contentflag_fbfc09f1` (`user_id`) +); + +-- +-- Table structure for table `demos_submission` +-- + +DROP TABLE IF EXISTS `demos_submission`; +CREATE TABLE `demos_submission` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `title` varchar(255) NOT NULL, + `slug` varchar(50) NOT NULL, + `summary` varchar(255) NOT NULL, + `description` longtext NOT NULL, + `featured` tinyint(1) NOT NULL, + `hidden` tinyint(1) NOT NULL, + `tags` varchar(255) NOT NULL, + `screenshot_1` varchar(100) NOT NULL, + `screenshot_2` varchar(100) NOT NULL, + `screenshot_3` varchar(100) NOT NULL, + `screenshot_4` varchar(100) NOT NULL, + `screenshot_5` varchar(100) NOT NULL, + `video_url` varchar(200) DEFAULT NULL, + `demo_package` varchar(100) NOT NULL, + `source_code_url` varchar(200) DEFAULT NULL, + `license_name` varchar(64) NOT NULL, + `creator_id` int(11) DEFAULT NULL, + `created` datetime NOT NULL, + `modified` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `title` (`title`), + UNIQUE KEY `slug` (`slug`), + KEY `demos_submission_f97a5119` (`creator_id`) +); + +-- +-- Table structure for table `demos_tagdescription` +-- + +DROP TABLE IF EXISTS `demos_tagdescription`; +CREATE TABLE `demos_tagdescription` ( + `tag_name` varchar(50) NOT NULL, + `title` varchar(255) NOT NULL, + `description` longtext NOT NULL, + PRIMARY KEY (`tag_name`), + UNIQUE KEY `title` (`title`) +); + +-- +-- Dumping data for table `demos_tagdescription` +-- + +INSERT INTO `demos_tagdescription` VALUES +('audio','Audio','These demos make noise'), +('canvas','Canvas','These demos make pretty pictures'), +('css3','CSS3','Fancy styling happens in these demos'), +('device','Device','Demos here use device thingies'), +('file','File','Files are manipulated here'), +('game','Game','Games are demos too!'), +('geolocation','Geolocation','These demos know where you\'ve been'), +('html5','HTML5','HTML5 is the future!'), +('indexeddb','IndexedDB','Data gets indexed happily'), +('mobile','Mobile','Demos on the march!'), +('svg','SVG','Drawrings in demos vectorly'), +('video','Video','Internet killed the video star'), +('webgl','WebGL','The browser is throwing things at your face in 3D'), +('websockets','WebSockets','Stick these in your socket and network it'), +('forms','Forms','Filling out fields just got better'), +('mathml','MathML','Pretty math stuff'), +('smil','SMIL','SMILe, you\'re on candid web demos'), +('localstorage','Local Storage','Storing things locally'), +('offlinesupport','Offline Support','Going offline for awhile'), +('webworkers','Web Workers','Workers of the web unite!'); + +-- +-- Table structure for table `tagging_taggeditem` +-- + +DROP TABLE IF EXISTS `tagging_taggeditem`; +CREATE TABLE `tagging_taggeditem` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `tag_id` int(11) NOT NULL, + `content_type_id` int(11) NOT NULL, + `object_id` int(10) unsigned NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `tag_id` (`tag_id`,`content_type_id`,`object_id`), + KEY `tagging_taggeditem_3747b463` (`tag_id`), + KEY `tagging_taggeditem_e4470c6e` (`content_type_id`), + KEY `tagging_taggeditem_829e37fd` (`object_id`) +); + +-- +-- Table structure for table `tagging_tag` +-- + +DROP TABLE IF EXISTS `tagging_tag`; +CREATE TABLE `tagging_tag` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(50) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`) +); diff --git a/migrations/mdn/11-demo-room-comments.sql b/migrations/mdn/11-demo-room-comments.sql new file mode 100644 index 00000000000..18fc8541e80 --- /dev/null +++ b/migrations/mdn/11-demo-room-comments.sql @@ -0,0 +1,57 @@ +-- +-- New tables for comments on demo room submissions +-- ./manage.py sql threadedcomments +-- + +BEGIN; +DROP TABLE IF EXISTS `threadedcomments_threadedcomment`; +CREATE TABLE `threadedcomments_threadedcomment` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `content_type_id` integer NOT NULL, + `object_id` integer UNSIGNED NOT NULL, + `parent_id` integer, + `user_id` integer NOT NULL, + `date_submitted` datetime NOT NULL, + `date_modified` datetime NOT NULL, + `date_approved` datetime, + `comment` longtext NOT NULL, + `markup` integer, + `is_public` bool NOT NULL, + `is_approved` bool NOT NULL, + `ip_address` char(15) +) +; +ALTER TABLE `threadedcomments_threadedcomment` ADD CONSTRAINT `content_type_id_refs_id_af49ca3a` FOREIGN KEY (`content_type_id`) REFERENCES `django_content_type` (`id`); +ALTER TABLE `threadedcomments_threadedcomment` ADD CONSTRAINT `user_id_refs_id_3c567b6` FOREIGN KEY (`user_id`) REFERENCES `auth_user` (`id`); +ALTER TABLE `threadedcomments_threadedcomment` ADD CONSTRAINT `parent_id_refs_id_7ef2a789` FOREIGN KEY (`parent_id`) REFERENCES `threadedcomments_threadedcomment` (`id`); +DROP TABLE IF EXISTS `threadedcomments_freethreadedcomment`; +CREATE TABLE `threadedcomments_freethreadedcomment` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `content_type_id` integer NOT NULL, + `object_id` integer UNSIGNED NOT NULL, + `parent_id` integer, + `name` varchar(128) NOT NULL, + `website` varchar(200) NOT NULL, + `email` varchar(75) NOT NULL, + `date_submitted` datetime NOT NULL, + `date_modified` datetime NOT NULL, + `date_approved` datetime, + `comment` longtext NOT NULL, + `markup` integer, + `is_public` bool NOT NULL, + `is_approved` bool NOT NULL, + `ip_address` char(15) +) +; +ALTER TABLE `threadedcomments_freethreadedcomment` ADD CONSTRAINT `content_type_id_refs_id_b49ecca0` FOREIGN KEY (`content_type_id`) REFERENCES `django_content_type` (`id`); +ALTER TABLE `threadedcomments_freethreadedcomment` ADD CONSTRAINT `parent_id_refs_id_8c7f0b95` FOREIGN KEY (`parent_id`) REFERENCES `threadedcomments_freethreadedcomment` (`id`); +DROP TABLE IF EXISTS `threadedcomments_testmodel`; +CREATE TABLE `threadedcomments_testmodel` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `name` varchar(5) NOT NULL, + `is_public` bool NOT NULL, + `date` datetime NOT NULL +) +; +COMMIT; + diff --git a/migrations/mdn/12-demo-room-drop-tagdescriptions.sql b/migrations/mdn/12-demo-room-drop-tagdescriptions.sql new file mode 100644 index 00000000000..92976a59f4e --- /dev/null +++ b/migrations/mdn/12-demo-room-drop-tagdescriptions.sql @@ -0,0 +1,4 @@ +-- +-- Table for tag descriptions no longer needed +-- +DROP TABLE IF EXISTS `demos_tagdescription`; diff --git a/migrations/mdn/13-demo-room-actioncounters-rewrite.sql b/migrations/mdn/13-demo-room-actioncounters-rewrite.sql new file mode 100644 index 00000000000..c3dc0a5d2df --- /dev/null +++ b/migrations/mdn/13-demo-room-actioncounters-rewrite.sql @@ -0,0 +1,43 @@ +BEGIN; + +ALTER TABLE `demos_submission` ADD COLUMN ( + `likes_total` integer NOT NULL, + `likes_recent` integer NOT NULL, + `launches_total` integer NOT NULL, + `launches_recent` integer NOT NULL +); + +CREATE INDEX `demos_submission_2078387` ON `demos_submission` (`likes_total`); +CREATE INDEX `demos_submission_6ba6244d` ON `demos_submission` (`likes_recent`); +CREATE INDEX `demos_submission_1dc8f9` ON `demos_submission` (`launches_total`); +CREATE INDEX `demos_submission_3984f161` ON `demos_submission` (`launches_recent`); + +DROP TABLE IF EXISTS `actioncounters_actioncounterunique`; +CREATE TABLE `actioncounters_actioncounterunique` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `content_type_id` integer NOT NULL, + `object_pk` varchar(32) NOT NULL, + `name` varchar(64) NOT NULL, + `total` integer DEFAULT 0, + `ip` varchar(40), + `session_key` varchar(40), + `user_agent` varchar(255), + `user_id` integer, + `modified` datetime +) +; +ALTER TABLE `actioncounters_actioncounterunique` ADD CONSTRAINT `content_type_id_refs_id_a1fb3291` FOREIGN KEY (`content_type_id`) REFERENCES `django_content_type` (`id`); +ALTER TABLE `actioncounters_actioncounterunique` ADD CONSTRAINT `user_id_refs_id_b752f625` FOREIGN KEY (`user_id`) REFERENCES `auth_user` (`id`); + +CREATE INDEX `actioncounters_actioncounterunique_e4470c6e` ON `actioncounters_actioncounterunique` (`content_type_id`); +CREATE INDEX `actioncounters_actioncounterunique_52094d6e` ON `actioncounters_actioncounterunique` (`name`); +CREATE INDEX `actioncounters_actioncounterunique_49a8a8f2` ON `actioncounters_actioncounterunique` (`ip`); +CREATE INDEX `actioncounters_actioncounterunique_4cac0564` ON `actioncounters_actioncounterunique` (`session_key`); +CREATE INDEX `actioncounters_actioncounterunique_c8b0e61e` ON `actioncounters_actioncounterunique` (`user_agent`); +CREATE INDEX `actioncounters_actioncounterunique_fbfc09f1` ON `actioncounters_actioncounterunique` (`user_id`); + +DROP TABLE IF EXISTS `actioncounters_action`; +DROP TABLE IF EXISTS `actioncounters_actioncounter`; +DROP TABLE IF EXISTS `actioncounters_actionhit`; + +COMMIT; diff --git a/migrations/mdn/14-demo-room-comments-count-denormalized.sql b/migrations/mdn/14-demo-room-comments-count-denormalized.sql new file mode 100644 index 00000000000..e7c873811a4 --- /dev/null +++ b/migrations/mdn/14-demo-room-comments-count-denormalized.sql @@ -0,0 +1,7 @@ +BEGIN; + +ALTER TABLE `demos_submission` ADD COLUMN ( + `comments_total` integer DEFAULT 0 NOT NULL +); + +COMMIT; diff --git a/migrations/mdn/15-bug-634390-utf8-collation-for-search-fix.sql b/migrations/mdn/15-bug-634390-utf8-collation-for-search-fix.sql new file mode 100644 index 00000000000..7bc74bdf50d --- /dev/null +++ b/migrations/mdn/15-bug-634390-utf8-collation-for-search-fix.sql @@ -0,0 +1,30 @@ +-- +-- bug 634390 - some search queries cause exceptions with non-UTF8 charset in mysql +-- see also: http://wolfram.kriesing.de/blog/index.php/2007/convert-mysql-db-to-utf8 +-- +ALTER TABLE `actioncounters_actioncounterunique` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `contentflagging_contentflag` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `auth_group` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `auth_group_permissions` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `auth_message` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `auth_permission` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `auth_user` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `auth_user_groups` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `auth_user_user_permissions` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `demos_submission` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `django_admin_log` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `django_content_type` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `django_session` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `django_site` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `feeder_bundle` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `feeder_bundle_feeds` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `feeder_entry` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `feeder_feed` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `schema_version` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `tagging_tag` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `tagging_taggeditem` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `threadedcomments_freethreadedcomment` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `threadedcomments_testmodel` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `threadedcomments_threadedcomment` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; +ALTER TABLE `user_profiles` CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci; + diff --git a/migrations/mdn/16-bug-634744-navbar-optout-field.sql b/migrations/mdn/16-bug-634744-navbar-optout-field.sql new file mode 100644 index 00000000000..bfa50024c44 --- /dev/null +++ b/migrations/mdn/16-bug-634744-navbar-optout-field.sql @@ -0,0 +1,10 @@ +-- +-- bug 634744: Adding a field to allow opt-out of navbar and iframe +-- +BEGIN; + +ALTER TABLE `demos_submission` ADD COLUMN ( + `navbar_optout` tinyint(1) DEFAULT 0 NOT NULL +); + +COMMIT; diff --git a/migrations/mdn/schematic_settings.py b/migrations/mdn/schematic_settings.py new file mode 100644 index 00000000000..ff7ceba3fe8 --- /dev/null +++ b/migrations/mdn/schematic_settings.py @@ -0,0 +1,30 @@ +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Set up zamboni. +import manage +from django.conf import settings + +config = settings.DATABASES['default'] +config['HOST'] = config.get('HOST', 'localhost') +config['PORT'] = config.get('PORT', '3306') + +if not config['HOST'] or config['HOST'].endswith('.sock'): + """ Oh you meant 'localhost'! """ + config['HOST'] = 'localhost' + +s = 'mysql --silent {NAME} -h{HOST} -u{USER}' + +if config['PASSWORD']: + s += ' -p{PASSWORD}' +else: + del config['PASSWORD'] +if config['PORT']: + s += ' -P{PORT}' +else: + del config['PORT'] + +db = s.format(**config) +table = 'schema_version' diff --git a/settings.py b/settings.py index e488ef750cb..8c123b4917b 100644 --- a/settings.py +++ b/settings.py @@ -4,6 +4,8 @@ import os import platform +from django.utils.functional import lazy + from sumo_locales import LOCALES DEBUG = True @@ -40,6 +42,9 @@ # Put the aliases for your slave databases in this list SLAVE_DATABASES = [] +# Dekiwiki has a backend API. protocol://hostname:port +DEKIWIKI_ENDPOINT = 'http://developer-stage9.mozilla.org' + # Cache Settings #CACHE_BACKEND = 'caching.backends.memcached://localhost:11211' #CACHE_PREFIX = 'sumo:' @@ -73,10 +78,32 @@ 'zh-TW', ) -LANGUAGE_CHOICES = tuple([(i, LOCALES[i].native) for i in SUMO_LANGUAGES]) -LANGUAGES = dict([(i.lower(), LOCALES[i].native) for i in SUMO_LANGUAGES]) +#LANGUAGE_CHOICES = tuple([(i, LOCALES[i].native) for i in SUMO_LANGUAGES]) +#LANGUAGES = dict([(i.lower(), LOCALES[i].native) for i in SUMO_LANGUAGES]) + +#LANGUAGE_URL_MAP = dict([(i.lower(), i) for i in SUMO_LANGUAGES]) + +# Accepted locales +MDN_LANGUAGES = ('en-US', 'de', 'fr', 'el', 'es', 'fy-NL', 'ga-IE', 'hr', 'hu', 'ko', 'ja', 'nl', 'pl', 'sl', 'sq', + 'zh-CN', 'zh-TW') +RTL_LANGUAGES = None # ('ar', 'fa', 'fa-IR', 'he') +LANGUAGE_URL_MAP = dict([(i.lower(), i) for i in MDN_LANGUAGES]) + +# DEKI uses different locale keys +LANGUAGE_DEKI_MAP = dict([(i, i) for i in MDN_LANGUAGES]) +LANGUAGE_DEKI_MAP['en-US'] = 'en' +LANGUAGE_DEKI_MAP['zh-CN'] = 'cn' +LANGUAGE_DEKI_MAP['zh-TW'] = 'zh_tw' + +# Override Django's built-in with our native names +class LazyLangs(dict): + def __new__(self): + from product_details import product_details + return dict([(lang.lower(), product_details.languages[lang]['native']) + for lang in MDN_LANGUAGES]) -LANGUAGE_URL_MAP = dict([(i.lower(), i) for i in SUMO_LANGUAGES]) +LANGUAGE_CHOICES = tuple([(i, LOCALES[i].native) for i in MDN_LANGUAGES]) +LANGUAGES = lazy(LazyLangs, dict)() TEXT_DOMAIN = 'messages' @@ -183,41 +210,61 @@ # TODO: Figure out why changing the order of apps (for example, moving taggit # higher in the list) breaks tests. INSTALLED_APPS = ( + # django 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.sites', 'django.contrib.messages', 'django.contrib.admin', - 'users', - 'tower', - 'jingo_minify', - ROOT_PACKAGE, - 'authority', - 'timezones', - 'access', - 'sumo', - 'search', - 'forums', - 'djcelery', + + # MDN + 'dekicompat', + 'devmo', + 'docs', + 'feeder', + 'landing', + + # DEMOS + 'demos', + 'captcha', + 'tagging', + 'contentflagging', + 'actioncounters', + 'threadedcomments', + + # util 'cronjobs', - 'notifications', - 'questions', - 'kadmin', - 'taggit', - 'flagit', - 'upload', + 'jingo_minify', 'product_details', + 'tower', + + # SUMO + #'users', + #ROOT_PACKAGE, + #'authority', + #'timezones', + #'access', + #'sumo', + #'search', + #'forums', + #'djcelery', + #'notifications', + #'questions', + #'kadmin', + #'taggit', + #'flagit', + #'upload', 'wiki', - 'kbforums', - 'dashboards', - 'gallery', - 'customercare', - 'twitter', - 'chat', - 'inproduct', - - # Extra apps for testing. + #'kbforums', + #'dashboards', + #'gallery', + #'customercare', + #'twitter', + #'chat', + #'inproduct', + + # testing. 'django_nose', 'test_utils', ) @@ -225,6 +272,8 @@ TEST_RUNNER = 'test_utils.runner.RadicalTestSuiteRunner' TEST_UTILS_NO_TRUNCATE = ('django_content_type',) +# Feed fetcher config +FEEDER_TIMEOUT = 6 # in seconds def JINJA_CONFIG(): import jinja2 diff --git a/templates/base.html b/templates/base.html index 7256178d34e..a56c381b1a6 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,139 +1,177 @@ -{# vim: set ts=2 et sts=2 sw=2: #} -{% from 'includes/common_macros.html' import search_box, greeting %} -{% from 'includes/sidebar_modules.html' import quick_links, for_contributors %} -{% if not top_link %} - {% set top_link = url('home') %} -{% endif %} -{% if not top_text %} - {% set top_text = _('Firefox Help') %} -{% endif %} - + - -{{ title }} | {{ _('Firefox Help', 'site_title') }} - - - -{% if feeds %} - {% for feed in feeds %} - - {% endfor %} -{% endif %} - -{{ css('common') }} -{% for style in styles %} -{{ css(style) }} -{% endfor %} - + {% block title %}{{ _('Mozilla Developer Network') }}{% endblock %} -{% if meta %} - {% for tag in meta %} - - {% endfor %} -{% endif %} + + + + + + -{% if canonical_url %} - -{% endif %} + + + - - - - -{% include 'layout/header.html' %} - -
            -
            - {% block sub_header %} - - {% if not hide_header_search %} - {{ search_box(settings, id='support-search') }} - {% endif %} - {{ greeting(user, settings) }} - {% endblock %} -
            - - {% block breadcrumbs %} - {{ breadcrumbs(crumbs) }} + {% block site_css %} + + {{ css('common') }} + + + + + {% endblock %} - {% block above_main %}{% endblock %} - -
            - {% block content %}{% endblock %} -
            - - {% block outer_side %}{# TODO: temporary wrapper block until questions and AoA get updated #} -
            - {% block side_top %}{% endblock %} - {{ quick_links(active=active_side_link) }} - {% block side %}{% endblock %} - {{ for_contributors(user) }} -
            - {% block side_promos %} -
            -

            {{ _('Be Awesome')|safe }}

            -
            - -
            -

            - {% trans %}Did you know that Firefox Help is powered by volunteer - superheroes all around the world?{% endtrans %} -

            - {{ _('Grab your cape and join us »')|safe }} -
            - {% if not hide_plugin_check %} -
            -

            {{ _('Check your plugins')|safe }}

            -
            - -
            -

            - {% trans %} - What is a plugin? Why should I keep them updated? Answer these - questions and run an instant check to see if you're up to date. - {% endtrans %} -

            - {{ _('Find out more »')|safe }} -
            - {% endif %} + + + {% block extrahead %}{% endblock %} + + + + +
            +
            +
            + {% block headerlogo %} + + {% endblock %} + {% block headertagline %} +

            {{ _('A comprehensive, usable, and accurate resource for everyone developing for the Open Web.') }}

            {% endblock %}
            + +
            + {% include "includes/login.html" %} + + +

            + + +

            + + +
            +
            + + {% block headernav %} + + {% endblock %} +
            - {% endblock %} +
            +{# end head #} + +{% block content %}{% endblock %} + +{# footer #} +
            +
            +

            + {% trans uservoice_url='http://mdn.uservoice.com/forums/51389-mdn-website-feedback-http-developer-mozilla-org' %} + What do you think of the new MDN? Please share + your feedback with us. + {% endtrans %} +

            +
            +
            +
            +
            + + {% include "includes/login.html" %} + {% include "includes/lang_switcher.html" %} +
            +
            + +{# js #} +{% block site_js %} + + {{ js('common') }} + + +{% endblock %} +{% block js %}{% endblock %} +{# end js #} -
            {# /#content #} - -
            - {# /#footer-contents #} -
            {# /#footer #} - - -{{ js('common') }} -{% for script in scripts %} -{{ js(script) }} -{% endfor %} - -{# Webtrends Stats Tracking #} - - -{# End Webtrends #} +{% include "includes/webtrends.html" %} diff --git a/templates/base_compact.html b/templates/base_compact.html new file mode 100644 index 00000000000..866c1db39e9 --- /dev/null +++ b/templates/base_compact.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} + +{# base.html with compact header. #} + +{% block headerclass %}compact{% endblock %} + +{% block headerlogo %} +

            Mozilla Developer Network

            +{% endblock %} + +{% block headertagline %}{% endblock %} + +{% block headernav %} + + {# TODO: Make this adapt to current section - only Demo Studio uses this compact layout, so far #} + + + +{% endblock %} + +{% block site_js %} + {{ super() }} + + +{% endblock %} diff --git a/templates/base_major.html b/templates/base_major.html new file mode 100644 index 00000000000..968197282b0 --- /dev/null +++ b/templates/base_major.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{# base.html with major header. #} + +{% block headerclass %}major{% endblock %} + +{% block headerlogo %} +

            Mozilla Developer Network

            +{% endblock %} diff --git a/templates/includes/lang_switcher.html b/templates/includes/lang_switcher.html new file mode 100644 index 00000000000..b96a1801019 --- /dev/null +++ b/templates/includes/lang_switcher.html @@ -0,0 +1,11 @@ +
            + + + +
            diff --git a/templates/includes/login.html b/templates/includes/login.html new file mode 100644 index 00000000000..a124703b6ad --- /dev/null +++ b/templates/includes/login.html @@ -0,0 +1,12 @@ +{% if user.is_authenticated() %} +

            {{ _('Welcome back') }} | + {{ _('Log out') }}

            +{% else %} + {% if not PHPBB_LOGGED_IN %} +

            {{ _('Log in') }} | + {{ _('Become an MDN member') }}

            + {% else %} +

            {{ _('Welcome back') }} | + {{ _('Log out') }}

            + {% endif %} +{% endif %} diff --git a/templates/includes/video_player.html b/templates/includes/video_player.html new file mode 100644 index 00000000000..57bb1b0c91d --- /dev/null +++ b/templates/includes/video_player.html @@ -0,0 +1,15 @@ +{# String used by the JS Video Player #} +
            + {{ _('Close') }} + {# L10n: Label used before a list of links to download the video #} + {{ _('Download Formats:') }} + {# L10n: Browsers and Plugins useful for viewing video content #} +
            {% trans %}This video requires a browser with support for open video: + + or the Adobe Flash + Player. Alternatively, you may use the video download links + provided.{% endtrans %}
            +
            diff --git a/templates/includes/webtrends.html b/templates/includes/webtrends.html new file mode 100644 index 00000000000..f10fb0f6386 --- /dev/null +++ b/templates/includes/webtrends.html @@ -0,0 +1,33 @@ +{# + + + + + +#} +{# {# webtrends.js is part of the JS bundles. #} +{# + + + + +#} + + + +{# #} diff --git a/urls.py b/urls.py index 1e96bfc92f4..9a24e3828b5 100644 --- a/urls.py +++ b/urls.py @@ -5,42 +5,55 @@ from django.views.decorators.cache import cache_page import authority +import jingo admin.autodiscover() authority.autodiscover() urlpatterns = patterns('', - (r'^search', include('search.urls')), - (r'^forums', include('forums.urls')), - (r'^questions', include('questions.urls')), - (r'^flagged', include('flagit.urls')), - (r'^upload', include('upload.urls')), - (r'^kb', include('wiki.urls')), - (r'^gallery', include('gallery.urls')), - (r'^army-of-awesome', include('customercare.urls')), - (r'^chat', include('chat.urls')), - (r'^1', include('inproduct.urls')), + # Home / landing pages: + ('', include('landing.urls')), + ('', include('docs.urls')), + (r'^logout/$', 'dekicompat.views.logout'), + (r'^demos/', include('demos.urls')), + + # Django admin: + (r'^admin/', include(admin.site.urls)), + + #(r'^search', include('search.urls')), + #(r'^forums', include('forums.urls')), + #(r'^questions', include('questions.urls')), + #(r'^flagged', include('flagit.urls')), + #(r'^upload', include('upload.urls')), + #(r'^kb', include('wiki.urls')), + #(r'^gallery', include('gallery.urls')), + #(r'^army-of-awesome', include('customercare.urls')), + #(r'^chat', include('chat.urls')), + #(r'^1', include('inproduct.urls')), # Kitsune admin (not Django admin). - (r'^admin/', include('kadmin.urls')), + #(r'^admin/', include('kadmin.urls')), # Javascript translations. - url(r'^jsi18n/.*$', cache_page(60 * 60 * 24 * 365)(javascript_catalog), - {'domain': 'javascript', 'packages': ['kitsune']}, name='jsi18n'), + #url(r'^jsi18n/.*$', cache_page(60 * 60 * 24 * 365)(javascript_catalog), + # {'domain': 'javascript', 'packages': ['kitsune']}, name='jsi18n'), - url(r'^', include('dashboards.urls')), + #url(r'^', include('dashboards.urls')), # Users - ('', include('users.urls')), + #('', include('users.urls')), # Services and sundry. - (r'', include('sumo.urls')), + #(r'', include('sumo.urls')), ) # Handle 404 and 500 errors -handler404 = 'sumo.views.handle404' -handler500 = 'sumo.views.handle500' +def _error_page(request, status): + """Render error pages with jinja2.""" + return jingo.render(request, '%d.html' % status, status=status) +handler404 = lambda r: _error_page(r, 404) +handler500 = lambda r: _error_page(r, 500) if settings.DEBUG: media_url = settings.MEDIA_URL.lstrip('/').rstrip('/')