Merge branch 'remotes/gullydwarf-cfdv/f360_tagging' (early part) into mergetags
authorChristopher Allan Webber <cwebber@dustycloud.org>
Sat, 30 Jul 2011 18:09:01 +0000 (13:09 -0500)
committerChristopher Allan Webber <cwebber@dustycloud.org>
Sat, 30 Jul 2011 18:09:01 +0000 (13:09 -0500)
Conflicts:
mediagoblin/config_spec.ini
mediagoblin/edit/views.py
mediagoblin/util.py

1  2 
mediagoblin/config_spec.ini
mediagoblin/db/indexes.py
mediagoblin/db/models.py
mediagoblin/edit/forms.py
mediagoblin/edit/views.py
mediagoblin/submit/views.py
mediagoblin/templates/mediagoblin/user_pages/media.html
mediagoblin/util.py

index 28be5f343aad55195b945e1407e8cc1d3b1edd07,5aae6439d86bba5a9c6d6762b27f9f122caf4432..a296f0c193b15feb50175b470c0b7403d32b703b
@@@ -1,7 -1,7 +1,7 @@@
  [mediagoblin]
  # database stuff
  db_host = string()
 -db_name = string()
 +db_name = string(default="mediagoblin")
  db_port = integer()
  
  # 
@@@ -21,9 -21,11 +21,14 @@@ direct_remote_path = string(default="/m
  email_debug_mode = boolean(default=True)
  email_sender_address = string(default="notice@mediagoblin.example.org")
  
 +# Set to false to disable registrations
 +allow_registration = boolean(default=True)
 +
+ # tag parsing
+ tags_delimiter = string(default=",")
+ tags_case_sensitive = boolean(default=False)
+ tags_max_length = integer(default=50)
  # By default not set, but you might want something like:
  # "%(here)s/user_dev/templates/"
  local_templates = string()
@@@ -76,4 -78,4 +81,4 @@@ celeryd_eta_scheduler_precision = float
  
  # known lists
  celery_routes = string_list()
 -celery_imports = string_list()
 +celery_imports = string_list()
index a832e0139538d9182115991a25c837f15ab27b9b,d0e11311f5fada374151494902b55d0adf6cc398..30d43c98f23b93def3a065d560c8a1d5a30a244d
@@@ -45,13 -45,11 +45,13 @@@ REQUIRED READING
  To remove deprecated indexes
  ----------------------------
  
 -Removing deprecated indexes is easier, just do:
 +Removing deprecated indexes is the same, just move the index into the
 +deprecated indexes mapping.
  
 -INACTIVE_INDEXES = {
 -    'collection_name': [
 -        'deprecated_index_identifier1', 'deprecated_index_identifier2']}
 +DEPRECATED_INDEXES = {
 +    'collection_name': {
 +        'deprecated_index_identifier1': {
 +            'index': [index_foo_goes_here]}}
          
  ... etc.
  
@@@ -90,6 -88,21 +90,21 @@@ MEDIAENTRY_INDEXES = 
          # Indexing on uploaders and when media entries are created.
          # Used for showing a user gallery, etc.
          'index': [('uploader', ASCENDING),
+                   ('created', DESCENDING)]},
+     'state_uploader_tags_created': {
+         # Indexing on processed?, media uploader, associated tags, and timestamp
+         # Used for showing media items matching a tag search, most recent first.
+         'index': [('state', ASCENDING),
+                   ('uploader', ASCENDING),
+                   ('tags.slug', DESCENDING),
+                   ('created', DESCENDING)]},
+     'state_tags_created': {
+         # Indexing on processed?, media tags, and timestamp (across all users)
+         # This is used for a front page tag search.
+         'index': [('state', ASCENDING),
+                   ('tags.slug', DESCENDING),
                    ('created', DESCENDING)]}}
  
  
diff --combined mediagoblin/db/models.py
index bad15acad7c8ab595bdbeb1450097049565b174a,8fcbb208b4b1dc970a23842cda4f33972c9024fb..4ef2d928466ca67500a1352cec2814a443a7c0d8
  
  import datetime, uuid
  
 -from mongokit import Document, Set
 +from mongokit import Document
  
  from mediagoblin import util
  from mediagoblin.auth import lib as auth_lib
  from mediagoblin import mg_globals
  from mediagoblin.db import migrations
  from mediagoblin.db.util import ASCENDING, DESCENDING, ObjectId
 +from mediagoblin.util import Pagination
 +from mediagoblin.util import DISPLAY_IMAGE_FETCHING_ORDER
 +
  
  ###################
  # Custom validators
  
  
  class User(Document):
 +    """
 +    A user of MediaGoblin.
 +
 +    Structure:
 +     - username: The username of this user, should be unique to this instance.
 +     - email: Email address of this user
 +     - created: When the user was created
 +     - plugin_data: a mapping of extra plugin information for this User.
 +       Nothing uses this yet as we don't have plugins, but someday we
 +       might... :)
 +     - pw_hash: Hashed version of user's password.
 +     - email_verified: Whether or not the user has verified their email or not.
 +       Most parts of the site are disabled for users who haven't yet.
 +     - status: whether or not the user is active, etc.  Currently only has two
 +       values, 'needs_email_verification' or 'active'.  (In the future, maybe
 +       we'll change this to a boolean with a key of 'active' and have a
 +       separate field for a reason the user's been disabled if that's
 +       appropriate... email_verified is already separate, after all.)
 +     - verification_key: If the user is awaiting email verification, the user
 +       will have to provide this key (which will be encoded in the presented
 +       URL) in order to confirm their email as active.
 +     - is_admin: Whether or not this user is an administrator or not.
 +     - url: this user's personal webpage/website, if appropriate.
 +     - bio: biography of this user (plaintext, in markdown)
 +     - bio_html: biography of the user converted to proper HTML.
 +    """
      __collection__ = 'users'
  
      structure = {
@@@ -76,8 -47,7 +76,8 @@@
          'verification_key': unicode,
          'is_admin': bool,
          'url' : unicode,
 -        'bio' : unicode
 +        'bio' : unicode,     # May contain markdown
 +        'bio_html': unicode, # May contain plaintext, or HTML
          }
  
      required_fields = ['username', 'created', 'pw_hash', 'email']
@@@ -88,6 -58,8 +88,6 @@@
          'status': u'needs_email_verification',
          'verification_key': lambda: unicode(uuid.uuid4()),
          'is_admin': False}
 -        
 -    migration_handler = migrations.UserMigration
  
      def check_login(self, password):
          """
  
  
  class MediaEntry(Document):
 +    """
 +    Record of a piece of media.
 +
 +    Structure:
 +     - uploader: A reference to a User who uploaded this.
 +
 +     - title: Title of this work
 +
 +     - slug: A normalized "slug" which can be used as part of a URL to retrieve
 +       this work, such as 'my-works-name-in-slug-form' may be viewable by
 +       'http://mg.example.org/u/username/m/my-works-name-in-slug-form/'
 +       Note that since URLs are constructed this way, slugs must be unique
 +       per-uploader.  (An index is provided to enforce that but code should be
 +       written on the python side to ensure this as well.)
 +
 +     - created: Date and time of when this piece of work was uploaded.
 +
 +     - description: Uploader-set description of this work.  This can be marked
 +       up with MarkDown for slight fanciness (links, boldness, italics,
 +       paragraphs...)
 +
 +     - description_html: Rendered version of the description, run through
 +       Markdown and cleaned with our cleaning tool.
 +
 +     - media_type: What type of media is this?  Currently we only support
 +       'image' ;)
 +
 +     - media_data: Extra information that's media-format-dependent.
 +       For example, images might contain some EXIF data that's not appropriate
 +       to other formats.  You might store it like:
 +
 +         mediaentry['media_data']['exif'] = {
 +             'manufacturer': 'CASIO',
 +             'model': 'QV-4000',
 +             'exposure_time': .659}
 +
 +       Alternately for video you might store:
 +
 +         # play length in seconds
 +         mediaentry['media_data']['play_length'] = 340
 +
 +       ... so what's appropriate here really depends on the media type.
 +
 +     - plugin_data: a mapping of extra plugin information for this User.
 +       Nothing uses this yet as we don't have plugins, but someday we
 +       might... :)
 +
 +     - tags: A list of tags.  Each tag is stored as a dictionary that has a key
 +       for the actual name and the normalized name-as-slug, so ultimately this
 +       looks like:
 +         [{'name': 'Gully Gardens',
 +           'slug': 'gully-gardens'},
 +          {'name': 'Castle Adventure Time?!",
 +           'slug': 'castle-adventure-time'}]
 +
 +     - state: What's the state of this file?  Active, inactive, disabled, etc...
 +       But really for now there are only two states:
 +        "unprocessed": uploaded but needs to go through processing for display
 +        "processed": processed and able to be displayed
 +
 +     - queued_media_file: storage interface style filepath describing a file
 +       queued for processing.  This is stored in the mg_globals.queue_store
 +       storage system.
 +
 +     - media_files: Files relevant to this that have actually been processed
 +       and are available for various types of display.  Stored like:
 +         {'thumb': ['dir1', 'dir2', 'pic.png'}
 +
 +     - attachment_files: A list of "attachment" files, ones that aren't
 +       critical to this piece of media but may be usefully relevant to people
 +       viewing the work.  (currently unused.)
 +
 +     - thumbnail_file: Deprecated... we should remove this ;)
 +    """
      __collection__ = 'media_entries'
  
      structure = {
          'media_type': unicode,
          'media_data': dict, # extra data relevant to this media_type
          'plugin_data': dict, # plugins can dump stuff here.
-         'tags': [unicode],
+         'tags': [dict],
          'state': unicode,
  
          # For now let's assume there can only be one main file queued
          'created': datetime.datetime.utcnow,
          'state': u'unprocessed'}
  
 -    migration_handler = migrations.MediaEntryMigration
 -
      def get_comments(self):
          return self.db.MediaComment.find({
                  'media_entry': self['_id']}).sort('created', DESCENDING)
  
 +    def get_display_media(self, media_map, fetch_order=DISPLAY_IMAGE_FETCHING_ORDER):
 +        """
 +        Find the best media for display.
 +
 +        Args:
 +        - media_map: a dict like
 +          {u'image_size': [u'dir1', u'dir2', u'image.jpg']}
 +        - fetch_order: the order we should try fetching images in
 +
 +        Returns:
 +        (media_size, media_path)
 +        """
 +        media_sizes = media_map.keys()
 +
 +        for media_size in DISPLAY_IMAGE_FETCHING_ORDER:
 +            if media_size in media_sizes:
 +                return media_map[media_size]
 +
      def main_mediafile(self):
          pass
  
  
          duplicate = mg_globals.database.media_entries.find_one(
              {'slug': self['slug']})
 -        
 +
          if duplicate:
              self['slug'] = "%s-%s" % (self['_id'], self['slug'])
  
                  'mediagoblin.user_pages.media_home',
                  user=uploader['username'],
                  media=unicode(self['_id']))
 -            
 +
      def url_to_prev(self, urlgen):
          """
          Provide a url to the previous entry from this user, if there is one
          """
 -        cursor = self.db.MediaEntry.find({'_id' : {"$gt": self['_id']}, 
 +        cursor = self.db.MediaEntry.find({'_id' : {"$gt": self['_id']},
                                            'uploader': self['uploader'],
                                            'state': 'processed'}).sort(
                                                      '_id', ASCENDING).limit(1)
              return urlgen('mediagoblin.user_pages.media_home',
                            user=self.uploader()['username'],
                            media=unicode(cursor[0]['slug']))
 -        
 +
      def url_to_next(self, urlgen):
          """
          Provide a url to the next entry from this user, if there is one
          """
 -        cursor = self.db.MediaEntry.find({'_id' : {"$lt": self['_id']}, 
 +        cursor = self.db.MediaEntry.find({'_id' : {"$lt": self['_id']},
                                            'uploader': self['uploader'],
                                            'state': 'processed'}).sort(
                                                      '_id', DESCENDING).limit(1)
  
  
  class MediaComment(Document):
 +    """
 +    A comment on a MediaEntry.
 +
 +    Structure:
 +     - media_entry: The media entry this comment is attached to
 +     - author: user who posted this comment
 +     - created: when the comment was created
 +     - content: plaintext (but markdown'able) version of the comment's content.
 +     - content_html: the actual html-rendered version of the comment displayed.
 +       Run through Markdown and the HTML cleaner.
 +    """
 +
      __collection__ = 'media_comments'
  
      structure = {
      def author(self):
          return self.db.User.find_one({'_id': self['author']})
  
 +
  REGISTER_MODELS = [
      MediaEntry,
      User,
index 0ed52af11ea4a24b6265b735197fdc9006d2d454,e7a86bba295493f448cb15581bb52ef24e186d5c..a1783a726f6e0b1b7e7435dc20dda54169b7a98b
@@@ -16,6 -16,7 +16,7 @@@
  
  
  import wtforms
+ from mediagoblin.util import tag_length_validator, TOO_LONG_TAG_WARNING
  
  
  class EditForm(wtforms.Form):
          'Title',
          [wtforms.validators.Length(min=0, max=500)])
      slug = wtforms.TextField(
 -        'Slug')
 +        'Slug',
 +        [wtforms.validators.Required(message="The slug can't be empty")])
      description = wtforms.TextAreaField('Description of this work')
+     tags = wtforms.TextField(
+         'Tags',
+         [tag_length_validator])
  
  class EditProfileForm(wtforms.Form):
      bio = wtforms.TextAreaField('Bio',
index f372fbb9e00f2dce66324bfd060233d8782c09e2,e4ebe8d797ee47b4f963edbe811c124765c28ca1..5cbaadb5e52cf68482b9ab1641aa3e85e1c59e04
  
  
  from webob import exc
+ from string import split
  
  from mediagoblin import messages
+ from mediagoblin import mg_globals
  from mediagoblin.util import (
-     render_to_response, redirect, cleaned_markdown_conversion)
+     render_to_response, redirect, clean_html, convert_to_tag_list_of_dicts,
 -    media_tags_as_string)
++    media_tags_as_string, cleaned_markdown_conversion)
  from mediagoblin.edit import forms
  from mediagoblin.edit.lib import may_edit_media
  from mediagoblin.decorators import require_active_login, get_user_media_entry
  
 -import markdown
 -
  
  @get_user_media_entry
  @require_active_login
@@@ -34,7 -39,8 +37,8 @@@ def edit_media(request, media)
      form = forms.EditForm(request.POST,
          title = media['title'],
          slug = media['slug'],
-         description = media['description'])
+         description = media['description'],
+         tags = media_tags_as_string(media['tags']))
  
      if request.method == 'POST' and form.validate():
          # Make sure there isn't already a MediaEntry with such a slug
          else:
              media['title'] = request.POST['title']
              media['description'] = request.POST.get('description')
+             media['tags'] = convert_to_tag_list_of_dicts(
+                                    request.POST.get('tags'))
+             
 -            md = markdown.Markdown(
 -                safe_mode = 'escape')
 -            media['description_html'] = clean_html(
 -                md.convert(
 -                    media['description']))
 +            media['description_html'] = cleaned_markdown_conversion(
 +                media['description'])
  
              media['slug'] = request.POST['slug']
              media.save()
@@@ -97,9 -108,6 +103,9 @@@ def edit_profile(request)
      if request.method == 'POST' and form.validate():
              user['url'] = request.POST['url']
              user['bio'] = request.POST['bio']
 +
 +            user['bio_html'] = cleaned_markdown_conversion(user['bio'])
 +
              user.save()
  
              messages.add_message(request, 
                                 'Profile edited!')
              return redirect(request,
                             'mediagoblin.user_pages.user_home',
 -                          username=edit_username)
 +                          user=edit_username)
  
      return render_to_response(
          request,
index 1848f5e58aa0844f4963d626ce26925ce4749e6d,c5ac8c6207c5341a81acab6dffdd4a4094c6e9f8..87e57ddab2321157871d4f11ce8ba456a959bc55
  
  from os.path import splitext
  from cgi import FieldStorage
+ from string import split
  
  from werkzeug.utils import secure_filename
  
  from mediagoblin.util import (
-     render_to_response, redirect, cleaned_markdown_conversion)
+     render_to_response, redirect, cleaned_markdown_conversion, \
+     convert_to_tag_list_of_dicts)
  from mediagoblin.decorators import require_active_login
  from mediagoblin.submit import forms as submit_forms, security
  from mediagoblin.process_media import process_media_initial
@@@ -59,6 -61,10 +61,10 @@@ def submit_start(request)
              entry['media_type'] = u'image' # heh
              entry['uploader'] = request.user['_id']
  
+             # Process the user's folksonomy "tags"
+             entry['tags'] = convert_to_tag_list_of_dicts(
+                                 request.POST.get('tags'))
              # Save, just so we can get the entry id for the sake of using
              # it to generate the file path
              entry.save(validate=False)
          request,
          'mediagoblin/submit/start.html',
          {'submit_form': submit_form})
 -
 -
 -def submit_success(request):
 -    return render_to_response(
 -        request, 'mediagoblin/submit/success.html', {})
index dc0b6210158b2817094e5042f0fb8e6900854e59,8dd42115acb672ffa644c55843745016c362b306..7622d6e66473abeb9e6006ef476f6419e72719f8
  {% block mediagoblin_content %}
    {% if media %}
      <div class="grid_11 alpha">
 -      {% if media.media_files.medium %}
 -        <img src="{{ request.app.public_store.file_url(
 -                       media.media_files.medium) }}" />
 -      {% else %}
 -        <img src="{{ request.app.public_store.file_url(
 -                         media.media_files.main) }}" />
 -      {% endif %}
 +      <img class="media_image" src="{{ request.app.public_store.file_url(
 +                                         media.get_display_media(media.media_files)) }}" />
  
        <h2>
          {{media.title}}
@@@ -50,7 -55,7 +50,7 @@@
          <form action="{{ request.urlgen('mediagoblin.user_pages.media_post_comment', 
                                           user= media.uploader().username,
                                           media=media._id) }}" method="POST">
 -          {{ wtforms_util.render_field_div(comment_form.comment) }}
 +          {{ wtforms_util.render_field_div(comment_form.comment_content) }}
            <div class="form_submit_buttons">
              <input type="submit" value="Post comment!" class="button" />
            </div>
        {% if comments %}
          {% for comment in comments %}
            {% set comment_author = comment.author() %}
 -          <div class="comment_wrapper" id="comment-{{ comment['_id'] }}">
 +          {% if pagination.active_id == comment._id %}
 +              <div class="comment_wrapper comment_active" id="comment-{{ comment['_id'] }}">
 +              <a name="comment" id="comment"></a>
 +            {% else %}
 +              <div class="comment_wrapper" id="comment-{{ comment['_id'] }}">
 +          {% endif %}
              <div class="comment_content">
                {% autoescape False %}
                  {{ comment.content_html }}
                {{ comment_author['username'] }}</a> at 
              <!--</div>
              <div class="comment_datetime">-->
 -              <a href="#comment-{{ comment['_id'] }}">
 +              <a href="{{ request.urlgen('mediagoblin.user_pages.media_home.view_comment',
 +                     comment = comment['_id'],
 +                     user = media.uploader().username,
 +                     media = media._id) }}#comment">
                  {{ "%4d-%02d-%02d %02d:%02d"|format(comment.created.year,
                                           comment.created.month,
                                           comment.created.day,
            </div>
          {% endfor %}
  
 -        {{ render_pagination(request, pagination) }}
 +        {{ render_pagination(request, pagination, 
 +            request.urlgen('mediagoblin.user_pages.media_home',
 +            user = media.uploader().username,
 +            media = media._id)) }}
        </div>
      {% endif %}
      <div class="grid_5 omega">
            </p>
          {% endif %}
        </p>
+       {% if media.tags %}
+         {% include "mediagoblin/utils/tags.html" %}
+       {% endif %}
      </div>
    {% else %}
      <p>Sorry, no such media found.<p/>
diff --combined mediagoblin/util.py
index 1892378ccf6db98e30586131319c88a8a5d0ec71,f051dc507ff9ee6cf5a82ecc87d1f40cdcdaf927..bb9f6db4838e065b5516c6f68bb840d9e5d20902
@@@ -14,8 -14,6 +14,8 @@@
  # You should have received a copy of the GNU Affero General Public License
  # along with this program.  If not, see <http://www.gnu.org/licenses/>.
  
 +from __future__ import division
 +
  from email.MIMEText import MIMEText
  import gettext
  import pkg_resources
@@@ -23,8 -21,10 +23,9 @@@ import smtpli
  import sys
  import re
  import urllib
 -from math import ceil
 -from string import strip
 +from math import ceil, floor
  import copy
+ import wtforms
  
  from babel.localedata import exists
  import jinja2
@@@ -37,10 -37,6 +38,10 @@@ from mediagoblin import mg_global
  from mediagoblin import messages
  from mediagoblin.db.util import ObjectId
  
 +from itertools import izip, count
 +
 +DISPLAY_IMAGE_FETCHING_ORDER = [u'medium', u'original', u'thumb']
 +
  TESTS_ENABLED = False
  def _activate_testing():
      """
@@@ -139,16 -135,7 +140,16 @@@ def render_to_response(request, templat
  
  def redirect(request, *args, **kwargs):
      """Returns a HTTPFound(), takes a request and then urlgen params"""
 -    return exc.HTTPFound(location=request.urlgen(*args, **kwargs))
 +    
 +    querystring = None
 +    if kwargs.get('querystring'):
 +        querystring = kwargs.get('querystring')
 +        del kwargs['querystring']
 +
 +    return exc.HTTPFound(
 +        location=''.join([
 +                request.urlgen(*args, **kwargs),
 +                querystring if querystring else '']))
  
  
  def setup_user_in_request(request):
@@@ -267,9 -254,9 +268,9 @@@ def send_email(from_addr, to_addrs, sub
       - message_body: email body text
      """
      # TODO: make a mock mhost if testing is enabled
 -    if TESTS_ENABLED or mg_globals.email_debug_mode:
 +    if TESTS_ENABLED or mg_globals.app_config['email_debug_mode']:
          mhost = FakeMhost()
 -    elif not mg_globals.email_debug_mode:
 +    elif not mg_globals.app_config['email_debug_mode']:
          mhost = smtplib.SMTP()
  
      mhost.connect()
      if TESTS_ENABLED:
          EMAIL_TEST_INBOX.append(message)
  
 -    if getattr(mg_globals, 'email_debug_mode', False):
 +    if mg_globals.app_config['email_debug_mode']:
          print u"===== Email ====="
          print u"From address: %s" % message['From']
          print u"To addresses: %s" % message['To']
@@@ -384,9 -371,67 +385,67 @@@ def clean_html(html)
      return HTML_CLEANER.clean_html(html)
  
  
- MARKDOWN_INSTANCE = markdown.Markdown(safe_mode='escape')
+ def convert_to_tag_list_of_dicts(tag_string):
+     """
+     Filter input from incoming string containing user tags,
+     Strips trailing, leading, and internal whitespace, and also converts
+     the "tags" text into an array of tags
+     """
+     taglist = []
+     if tag_string:
+         # Strip out internal, trailing, and leading whitespace
+         stripped_tag_string = u' '.join(tag_string.strip().split())
+         # Split the tag string into a list of tags
+         for tag in stripped_tag_string.split(
+                                        mg_globals.app_config['tags_delimiter']):
+             # Ignore empty or duplicate tags
+             if tag.strip() and tag.strip() not in [t['name'] for t in taglist]:
+                 if mg_globals.app_config['tags_case_sensitive']:
+                     taglist.append({'name': tag.strip(),
+                                     'slug': slugify(tag.strip())})
+                 else:
+                     taglist.append({'name': tag.strip().lower(),
+                                     'slug': slugify(tag.strip().lower())})
+     return taglist
+ def media_tags_as_string(media_entry_tags):
+     """
+     Generate a string from a media item's tags, stored as a list of dicts
+     This is the opposite of convert_to_tag_list_of_dicts
+     """
+     media_tag_string = ''
+     if media_entry_tags:
+         media_tag_string = mg_globals.app_config['tags_delimiter'].join(
+                                       [tag['name'] for tag in media_entry_tags])
+     return media_tag_string
+ TOO_LONG_TAG_WARNING = \
+     u'Tags must be shorter than %s characters.  Tags that are too long: %s'
+ def tag_length_validator(form, field):
+     """
+     Make sure tags do not exceed the maximum tag length.
+     """
+     tags = convert_to_tag_list_of_dicts(field.data)
+     too_long_tags = [
+         tag['name'] for tag in tags
+         if len(tag['name']) > mg_globals.app_config['tags_max_length']]
+     if too_long_tags:
+         raise wtforms.ValidationError(
+             TOO_LONG_TAG_WARNING % (mg_globals.app_config['tags_max_length'], \
+                                     ', '.join(too_long_tags)))
  
  
+ MARKDOWN_INSTANCE = markdown.Markdown(safe_mode='escape')
  def cleaned_markdown_conversion(text):
      """
      Take a block of text, run it through MarkDown, and clean its HTML.
@@@ -433,8 -478,7 +492,8 @@@ class Pagination(object)
      get actual data slice through __call__().
      """
  
 -    def __init__(self, page, cursor, per_page=PAGINATION_DEFAULT_PER_PAGE):
 +    def __init__(self, page, cursor, per_page=PAGINATION_DEFAULT_PER_PAGE,
 +                 jump_to_id=False):
          """
          Initializes Pagination
  
           - page: requested page
           - per_page: number of objects per page
           - cursor: db cursor 
 +         - jump_to_id: ObjectId, sets the page to the page containing the object
 +           with _id == jump_to_id.
          """
 -        self.page = page    
 +        self.page = page
          self.per_page = per_page
          self.cursor = cursor
          self.total_count = self.cursor.count()
 +        self.active_id = None
 +
 +        if jump_to_id:
 +            cursor = copy.copy(self.cursor)
 +
 +            for (doc, increment) in izip(cursor, count(0)):
 +                if doc['_id'] == jump_to_id:
 +                    self.page = 1 + int(floor(increment / self.per_page))
 +
 +                    self.active_id = jump_to_id
 +                    break
 +
  
      def __call__(self):
          """