diff --git a/src/calibre/www/apps/blog/CHANGELOG.yml b/src/calibre/www/apps/blog/CHANGELOG.yml
new file mode 100644
index 0000000000..01106f1ad6
--- /dev/null
+++ b/src/calibre/www/apps/blog/CHANGELOG.yml
@@ -0,0 +1,85 @@
+changes:
+ date: 2008-09-17
+ change: Enabled the ability to override the default template names.
+
+ date: 2008-08-26
+ change: Upgraded post_detail.html to now use new Django refactored comments. Sidenote: basic.remarks have been removed.
+
+ date: 2008-07-14
+ change: Removed get_query_set from Blog manager to fix a problem where saving a post marked as Draft would not save.
+ change: Added a get_previous_post and get_next_post method for front end template. These will not return Draft posts.
+
+ date: 2008-06-17
+ change: BlogPostFeed is now BlogPostsFeed and there is a new BlogPostsByCategory.
+
+ date: 2008-05-18
+ change: Converted everything to 4 space tabs and made a few other changes to comply with Python Style Guide.
+
+ date: 2008-04-23
+ change: Added an inline admin interface helper for choosing inlines to go into posts.
+ change: The inline app is now a dependancy of the blog.
+
+ date: 2008-04-22
+ change: Removed the 'render_inlines' filter from the Blog template tags. The tag is now in an app called inlines which can be used with any django app.
+
+ date: 2008-02-27
+ change: Added 'allow_comments' field to the Post model.
+ change: Removed 'Closed' choice from status field of Post model
+
+ date: 2008-02-18
+ fix: Fixed feed pointing to hardcoded url.
+
+ date: 2008-02-15
+ change: Internationalized models
+
+ date: 2008-02-04
+ change: Added 'get_links' template filter.
+ change: Templates: added a {% block content_title %}
+
+ date: 2008-02-02
+ change: Added a sitemap
+
+ date: 2008-01-30
+ change: Renamed 'do_inlines' filter to 'render_inlines'
+
+ date: 2008-01-29
+ change: BeautifulSoup is no longer a dependancy unless you want to use the do_inlines filter.
+
+ date: 2008-01-27
+ fix: removed 'tagging.register(Post)' from model. It was causing too many unnecessary SQL JOINS.
+ change: Changed the inlines tag to a filter. (Example: {{ object.text|do_inlines }})
+
+ date: 2008-01-22
+ change: Registered the Post model with the tagging app
+
+ date: 2008-01-19
+ change: Renamed the 'list' class to 'link_list'
+
+ date: 2008-01-09
+ change: Changed urls.py so you can have /posts/page/2/ or /posts/?page=2
+
+ date: 2008-01-07
+ change: Removed PublicPostManager in favor of ManagerWithPublished.
+ change: Made wrappers for generic views.
+
+ date: 2008-01-06
+ fix: In blog.py changed 'beautifulsoup' to 'BeautifulSoup'
+
+ date: 2007-12-31
+ change: Changed some syntax in managers.py to hopefully fix a bug.
+ change: Removed an inline template that didn't belong.
+
+ date: 2007-12-21
+ change: Added markup tag that formats inlines.
+
+ date: 2007-12-12
+ change: Cleaned up unit tests.
+
+ date: 2007-12-11
+ change: Add documentation to templatetags and views.
+ change: Smartened up the previous/next blog part of the post_detail.html template.
+
+ date: 2007-12-09
+ change: Added feed templates and wrapped up feeds.py.
+ change: Changed Post.live manager to Post.public
+ change: Added a search view along with templates
diff --git a/src/calibre/www/apps/blog/README.txt b/src/calibre/www/apps/blog/README.txt
new file mode 100644
index 0000000000..588bef40a1
--- /dev/null
+++ b/src/calibre/www/apps/blog/README.txt
@@ -0,0 +1,18 @@
+===========================================
+Django Basic Blog
+http://code.google.com/p/django-basic-apps/
+===========================================
+
+A simple blog application for Django projects.
+
+To install this app, simply create a folder somewhere in
+your PYTHONPATH named 'basic' and place the 'blog'
+app inside. Then add 'basic.blog' to your projects
+INSTALLED_APPS list in your settings.py file.
+
+=== Dependancies ===
+ * Basic Inlines
+ * [http://www.djangoproject.com/documentation/add_ons/#comments Django Comments]
+ * [http://code.google.com/p/django-tagging Django Tagging]
+ * [http://www.djangoproject.com/documentation/add_ons/#markup Markup]
+ * [http://www.crummy.com/software/BeautifulSoup/ BeautifulSoup] - only if you want to use the [http://code.google.com/p/django-basic-blog/wiki/BlogInlinesProposal render_inlines] filter, otherwise it's not necessary.
\ No newline at end of file
diff --git a/src/calibre/www/apps/blog/__init__.py b/src/calibre/www/apps/blog/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/calibre/www/apps/blog/admin.py b/src/calibre/www/apps/blog/admin.py
new file mode 100644
index 0000000000..089e2508e1
--- /dev/null
+++ b/src/calibre/www/apps/blog/admin.py
@@ -0,0 +1,17 @@
+from django.contrib import admin
+from calibre.www.apps.blog.models import *
+
+
+class CategoryAdmin(admin.ModelAdmin):
+ prepopulated_fields = {'slug': ('title',)}
+
+admin.site.register(Category, CategoryAdmin)
+
+
+class PostAdmin(admin.ModelAdmin):
+ list_display = ('title', 'publish', 'status')
+ list_filter = ('publish', 'categories', 'status')
+ search_fields = ('title', 'body')
+ prepopulated_fields = {'slug': ('title',)}
+
+admin.site.register(Post, PostAdmin)
\ No newline at end of file
diff --git a/src/calibre/www/apps/blog/feeds.py b/src/calibre/www/apps/blog/feeds.py
new file mode 100644
index 0000000000..95573ec9a7
--- /dev/null
+++ b/src/calibre/www/apps/blog/feeds.py
@@ -0,0 +1,42 @@
+from django.contrib.syndication.feeds import FeedDoesNotExist
+from django.core.exceptions import ObjectDoesNotExist
+from django.contrib.sites.models import Site
+from django.contrib.syndication.feeds import Feed
+from django.core.urlresolvers import reverse
+from calibre.www.apps.blog.models import Post, Category
+
+
+class BlogPostsFeed(Feed):
+ _site = Site.objects.get_current()
+ title = '%s feed' % _site.name
+ description = '%s posts feed.' % _site.name
+
+ def link(self):
+ return reverse('blog_index')
+
+ def items(self):
+ return Post.objects.published()[:10]
+
+ def item_pubdate(self, obj):
+ return obj.publish
+
+
+class BlogPostsByCategory(Feed):
+ _site = Site.objects.get_current()
+ title = '%s posts category feed' % _site.name
+
+ def get_object(self, bits):
+ if len(bits) != 1:
+ raise ObjectDoesNotExist
+ return Category.objects.get(slug__exact=bits[0])
+
+ def link(self, obj):
+ if not obj:
+ raise FeedDoesNotExist
+ return obj.get_absolute_url()
+
+ def description(self, obj):
+ return "Posts recently categorized as %s" % obj.title
+
+ def items(self, obj):
+ return obj.post_set.published()[:10]
\ No newline at end of file
diff --git a/src/calibre/www/apps/blog/managers.py b/src/calibre/www/apps/blog/managers.py
new file mode 100644
index 0000000000..d5bcdb005c
--- /dev/null
+++ b/src/calibre/www/apps/blog/managers.py
@@ -0,0 +1,9 @@
+from django.db.models import Manager
+import datetime
+
+
+class PublicManager(Manager):
+ """Returns published posts that are not in the future."""
+
+ def published(self):
+ return self.get_query_set().filter(status__gte=2, publish__lte=datetime.datetime.now())
\ No newline at end of file
diff --git a/src/calibre/www/apps/blog/models.py b/src/calibre/www/apps/blog/models.py
new file mode 100644
index 0000000000..b719d8cb8a
--- /dev/null
+++ b/src/calibre/www/apps/blog/models.py
@@ -0,0 +1,80 @@
+from django.db import models
+from django.utils.translation import ugettext_lazy as _
+from django.db.models import permalink
+from django.contrib.auth.models import User
+from calibre.www.apps.tagging.fields import TagField
+from calibre.www.apps.blog.managers import PublicManager
+
+import calibre.www.apps.tagging as tagging
+
+class Category(models.Model):
+ """Category model."""
+ title = models.CharField(_('title'), max_length=100)
+ slug = models.SlugField(_('slug'), unique=True)
+
+ class Meta:
+ verbose_name = _('category')
+ verbose_name_plural = _('categories')
+ db_table = 'blog_categories'
+ ordering = ('title',)
+
+ class Admin:
+ pass
+
+ def __unicode__(self):
+ return u'%s' % self.title
+
+ @permalink
+ def get_absolute_url(self):
+ return ('blog_category_detail', None, {'slug': self.slug})
+
+
+class Post(models.Model):
+ """Post model."""
+ STATUS_CHOICES = (
+ (1, _('Draft')),
+ (2, _('Public')),
+ )
+ title = models.CharField(_('title'), max_length=200)
+ slug = models.SlugField(_('slug'), unique_for_date='publish')
+ author = models.ForeignKey(User, blank=True, null=True)
+ body = models.TextField(_('body'))
+ tease = models.TextField(_('tease'), blank=True)
+ status = models.IntegerField(_('status'), choices=STATUS_CHOICES, default=2)
+ allow_comments = models.BooleanField(_('allow comments'), default=True)
+ publish = models.DateTimeField(_('publish'))
+ created = models.DateTimeField(_('created'), auto_now_add=True)
+ modified = models.DateTimeField(_('modified'), auto_now=True)
+ categories = models.ManyToManyField(Category, blank=True)
+ tags = TagField()
+ objects = PublicManager()
+
+ class Meta:
+ verbose_name = _('post')
+ verbose_name_plural = _('posts')
+ db_table = 'blog_posts'
+ ordering = ('-publish',)
+ get_latest_by = 'publish'
+
+ class Admin:
+ list_display = ('title', 'publish', 'status')
+ list_filter = ('publish', 'categories', 'status')
+ search_fields = ('title', 'body')
+
+ def __unicode__(self):
+ return u'%s' % self.title
+
+ @permalink
+ def get_absolute_url(self):
+ return ('blog_detail', None, {
+ 'year': self.publish.year,
+ 'month': self.publish.strftime('%b').lower(),
+ 'day': self.publish.day,
+ 'slug': self.slug
+ })
+
+ def get_previous_post(self):
+ return self.get_previous_by_publish(status__gte=2)
+
+ def get_next_post(self):
+ return self.get_next_by_publish(status__gte=2)
diff --git a/src/calibre/www/apps/blog/sitemap.py b/src/calibre/www/apps/blog/sitemap.py
new file mode 100644
index 0000000000..d78c71022e
--- /dev/null
+++ b/src/calibre/www/apps/blog/sitemap.py
@@ -0,0 +1,13 @@
+from django.contrib.sitemaps import Sitemap
+from calibre.www.apps.blog.models import Post
+
+
+class BlogSitemap(Sitemap):
+ changefreq = "never"
+ priority = 0.5
+
+ def items(self):
+ return Post.objects.published()
+
+ def lastmod(self, obj):
+ return obj.publish
\ No newline at end of file
diff --git a/src/calibre/www/apps/blog/templates/admin/blog/post/change_form.html b/src/calibre/www/apps/blog/templates/admin/blog/post/change_form.html
new file mode 100644
index 0000000000..08c034b77c
--- /dev/null
+++ b/src/calibre/www/apps/blog/templates/admin/blog/post/change_form.html
@@ -0,0 +1,56 @@
+{% extends "admin/change_form.html" %}
+
+{% block extrahead %}
+ {% load adminmedia inlines %}
+ {{ block.super }}
+
+{% endblock %}
\ No newline at end of file
diff --git a/src/calibre/www/apps/blog/templates/base.html b/src/calibre/www/apps/blog/templates/base.html
new file mode 100644
index 0000000000..880d8a996f
--- /dev/null
+++ b/src/calibre/www/apps/blog/templates/base.html
@@ -0,0 +1,21 @@
+
+
+
+
+
+ {% block title %}{% endblock %}
+
+
+
+ {% block body %}
+
+ {% block content_title %}{% endblock %}
+
+
+ {% block content %}{% endblock %}
+
+ {% endblock %}
+
+
+
\ No newline at end of file
diff --git a/src/calibre/www/apps/blog/templates/blog/base_blog.html b/src/calibre/www/apps/blog/templates/blog/base_blog.html
new file mode 100644
index 0000000000..b05c03e8b1
--- /dev/null
+++ b/src/calibre/www/apps/blog/templates/blog/base_blog.html
@@ -0,0 +1,4 @@
+{% extends "base.html" %}
+
+
+{% block body_class %}blog{% endblock %}
\ No newline at end of file
diff --git a/src/calibre/www/apps/blog/templates/blog/category_detail.html b/src/calibre/www/apps/blog/templates/blog/category_detail.html
new file mode 100644
index 0000000000..23a2579766
--- /dev/null
+++ b/src/calibre/www/apps/blog/templates/blog/category_detail.html
@@ -0,0 +1,25 @@
+{% extends "blog/base_blog.html" %}
+
+
+{% block title %}Posts for {{ category.title }}{% endblock %}
+{% block body_class %}{{ block.super }} category_detail{% endblock %}
+{% block body_id %}category_{{ category.id }}{% endblock %}
+
+
+{% block content_title %}
+ Posts for {{ category.title }}
+{% endblock %}
+
+
+{% block content %}
+ {% load markup %}
+
+ {% for post in object_list %}
+
+
+
{{ post.publish|date:"Y F d" }}
+
{{ post.tease }}
+
+ {% endfor %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/src/calibre/www/apps/blog/templates/blog/category_list.html b/src/calibre/www/apps/blog/templates/blog/category_list.html
new file mode 100644
index 0000000000..6ec01e46bc
--- /dev/null
+++ b/src/calibre/www/apps/blog/templates/blog/category_list.html
@@ -0,0 +1,20 @@
+{% extends "blog/base_blog.html" %}
+
+
+{% block title %}Post categories{% endblock %}
+{% block body_class %}{{ block.super }} category_list{% endblock %}
+
+
+{% block content_title %}
+ Post categories
+{% endblock %}
+
+
+{% block content %}
+ {% load markup %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/src/calibre/www/apps/blog/templates/blog/post_archive_day.html b/src/calibre/www/apps/blog/templates/blog/post_archive_day.html
new file mode 100644
index 0000000000..b9d9ab97fb
--- /dev/null
+++ b/src/calibre/www/apps/blog/templates/blog/post_archive_day.html
@@ -0,0 +1,23 @@
+{% extends "blog/base_blog.html" %}
+
+
+{% block title %}Post archive for {{ day|date:"d F Y" }}{% endblock %}
+{% block body_class %}{{ block.super }} post_archive_day{% endblock %}
+
+
+{% block content_title %}
+ Post archive for {{ day|date:"d F Y" }}
+{% endblock %}
+
+
+{% block content %}
+
+ {% for post in object_list %}
+
+
+
{{ post.publish|date:"Y F d" }}
+
{{ post.tease }}
+
+ {% endfor %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/src/calibre/www/apps/blog/templates/blog/post_archive_month.html b/src/calibre/www/apps/blog/templates/blog/post_archive_month.html
new file mode 100644
index 0000000000..947a1cc7d0
--- /dev/null
+++ b/src/calibre/www/apps/blog/templates/blog/post_archive_month.html
@@ -0,0 +1,23 @@
+{% extends "blog/base_blog.html" %}
+
+
+{% block title %}Post archive for {{ month|date:"F Y" }}{% endblock %}
+{% block body_class %}{{ block.super }} post_archive_month{% endblock %}
+
+
+{% block content_title %}
+ Post archive for {{ month|date:"F Y" }}
+{% endblock %}
+
+
+{% block content %}
+
+ {% for post in object_list %}
+
+
+
{{ post.publish|date:"Y F d" }}
+
{{ post.tease }}
+
+ {% endfor %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/src/calibre/www/apps/blog/templates/blog/post_archive_year.html b/src/calibre/www/apps/blog/templates/blog/post_archive_year.html
new file mode 100644
index 0000000000..f0255220b2
--- /dev/null
+++ b/src/calibre/www/apps/blog/templates/blog/post_archive_year.html
@@ -0,0 +1,21 @@
+{% extends "blog/base_blog.html" %}
+
+
+{% block title %}Post archive for {{ year }}{% endblock %}
+{% block body_class %}{{ block.super }} post_archive_year{% endblock %}
+
+
+{% block content_title %}
+ Post archive for {{ year }}
+{% endblock %}
+
+
+{% block content %}
+ {% load markup %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/src/calibre/www/apps/blog/templates/blog/post_detail.html b/src/calibre/www/apps/blog/templates/blog/post_detail.html
new file mode 100644
index 0000000000..a4247dcc4f
--- /dev/null
+++ b/src/calibre/www/apps/blog/templates/blog/post_detail.html
@@ -0,0 +1,67 @@
+{% extends "blog/base_blog.html" %}
+
+
+{% block title %}{{ object.title }}{% endblock %}
+{% block body_class %}{{ block.super }} post_detail{% endblock %}
+{% block body_id %}post_{{ object.id }}{% endblock %}
+
+
+{% block content_title %}
+ {{ object.title }}
+
+ {% if object.get_previous_by_publish %}
+ « {{ object.get_previous_post }}
+ {% endif %}
+ {% if object.get_next_by_publish %}
+ | {{ object.get_next_post }} »
+ {% endif %}
+
+{% endblock %}
+
+
+{% block content %}
+ {% load blog markup comments tagging_tags %}
+
+ {{ object.publish|date:"j F Y" }}
+
+
+ {{ object.body|markdown:"safe" }}
+
+
+ {% tags_for_object object as tag_list %}
+ {% if tag_list %}
+ Related tags:
+ {% for tag in tag_list %}
+ {{ tag }}{% if not forloop.last %}, {% endif %}
+ {% endfor %}
+
+ {% endif %}
+
+ {% get_comment_list for object as comment_list %}
+ {% if comment_list %}
+
+ {% endif %}
+ {% if object.allow_comments %}
+ {% render_comment_form for object %}
+ {% else %}
+
+ {% endif %}
+{% endblock %}
\ No newline at end of file
diff --git a/src/calibre/www/apps/blog/templates/blog/post_list.html b/src/calibre/www/apps/blog/templates/blog/post_list.html
new file mode 100644
index 0000000000..399b1f0e6f
--- /dev/null
+++ b/src/calibre/www/apps/blog/templates/blog/post_list.html
@@ -0,0 +1,35 @@
+{% extends "blog/base_blog.html" %}
+
+
+{% block title %}Post archive{% endblock %}
+{% block body_class %}{{ block.super }} post_list{% endblock %}
+
+
+{% block content_title %}
+ Post archive
+{% endblock %}
+
+
+{% block content %}
+
+ {% for post in object_list %}
+
+
+
{{ post.publish|date:"Y F d" }}
+
{{ post.tease }}
+
+ {% endfor %}
+
+
+ {% if is_paginated %}
+
+ {% endif %}
+{% endblock %}
\ No newline at end of file
diff --git a/src/calibre/www/apps/blog/templates/blog/post_search.html b/src/calibre/www/apps/blog/templates/blog/post_search.html
new file mode 100644
index 0000000000..2884333f83
--- /dev/null
+++ b/src/calibre/www/apps/blog/templates/blog/post_search.html
@@ -0,0 +1,37 @@
+{% extends "blog/base_blog.html" %}
+
+
+{% block title %}Post search{% endblock %}
+{% block body_class %}{{ block.super }} post_search{% endblock %}
+
+
+{% block content_title %}
+ Search
+{% endblock %}
+
+
+{% block content %}
+
+
+ {% if message %}
+ {{ message }}
+ {% endif %}
+
+ {% if object_list %}
+
+ {% for post in object_list %}
+
+
+
{{ post.publish|date:"Y F d" }}
+
{{ post.tease }}
+
+
+ {% endfor %}
+
+ {% endif %}
+{% endblock %}
\ No newline at end of file
diff --git a/src/calibre/www/apps/blog/templates/feeds/posts_description.html b/src/calibre/www/apps/blog/templates/feeds/posts_description.html
new file mode 100644
index 0000000000..99216e812d
--- /dev/null
+++ b/src/calibre/www/apps/blog/templates/feeds/posts_description.html
@@ -0,0 +1 @@
+{{ obj.tease }}
\ No newline at end of file
diff --git a/src/calibre/www/apps/blog/templates/feeds/posts_title.html b/src/calibre/www/apps/blog/templates/feeds/posts_title.html
new file mode 100644
index 0000000000..7899fce3e8
--- /dev/null
+++ b/src/calibre/www/apps/blog/templates/feeds/posts_title.html
@@ -0,0 +1 @@
+{{ obj.title }}
\ No newline at end of file
diff --git a/src/calibre/www/apps/blog/templates/inlines/default.html b/src/calibre/www/apps/blog/templates/inlines/default.html
new file mode 100644
index 0000000000..5510ba9521
--- /dev/null
+++ b/src/calibre/www/apps/blog/templates/inlines/default.html
@@ -0,0 +1,7 @@
+{% if object %}
+ {{ object }}
+{% else %}
+ {% for object in object_list %}
+ {{ object }}
+ {% endfor %}
+{% endif %}
\ No newline at end of file
diff --git a/src/calibre/www/apps/blog/templatetags/__init__.py b/src/calibre/www/apps/blog/templatetags/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/calibre/www/apps/blog/templatetags/blog.py b/src/calibre/www/apps/blog/templatetags/blog.py
new file mode 100644
index 0000000000..e80918cb39
--- /dev/null
+++ b/src/calibre/www/apps/blog/templatetags/blog.py
@@ -0,0 +1,103 @@
+from django import template
+from django.conf import settings
+from django.db import models
+
+import re
+
+Post = models.get_model('blog', 'post')
+Category = models.get_model('blog', 'category')
+
+register = template.Library()
+
+
+class LatestPosts(template.Node):
+ def __init__(self, limit, var_name):
+ self.limit = limit
+ self.var_name = var_name
+
+ def render(self, context):
+ posts = Post.objects.published()[:int(self.limit)]
+ if posts and (int(self.limit) == 1):
+ context[self.var_name] = posts[0]
+ else:
+ context[self.var_name] = posts
+ return ''
+
+@register.tag
+def get_latest_posts(parser, token):
+ """
+ Gets any number of latest posts and stores them in a varable.
+
+ Syntax::
+
+ {% get_latest_posts [limit] as [var_name] %}
+
+ Example usage::
+
+ {% get_latest_posts 10 as latest_post_list %}
+ """
+ try:
+ tag_name, arg = token.contents.split(None, 1)
+ except ValueError:
+ raise template.TemplateSyntaxError, "%s tag requires arguments" % token.contents.split()[0]
+ m = re.search(r'(.*?) as (\w+)', arg)
+ if not m:
+ raise template.TemplateSyntaxError, "%s tag had invalid arguments" % tag_name
+ format_string, var_name = m.groups()
+ return LatestPosts(format_string, var_name)
+
+
+class BlogCategories(template.Node):
+ def __init__(self, var_name):
+ self.var_name = var_name
+
+ def render(self, context):
+ categories = Category.objects.all()
+ context[self.var_name] = categories
+ return ''
+
+@register.tag
+def get_blog_categories(parser, token):
+ """
+ Gets all blog categories.
+
+ Syntax::
+
+ {% get_blog_categories as [var_name] %}
+
+ Example usage::
+
+ {% get_blog_categories as category_list %}
+ """
+ try:
+ tag_name, arg = token.contents.split(None, 1)
+ except ValueError:
+ raise template.TemplateSyntaxError, "%s tag requires arguments" % token.contents.split()[0]
+ m = re.search(r'as (\w+)', arg)
+ if not m:
+ raise template.TemplateSyntaxError, "%s tag had invalid arguments" % tag_name
+ var_name = m.groups()[0]
+ return BlogCategories(var_name)
+
+
+@register.filter
+def get_links(value):
+ """
+ Extracts links from a ``Post`` body and returns a list.
+
+ Template Syntax::
+
+ {{ post.body|markdown:"safe"|get_links }}
+
+ """
+ try:
+ try:
+ from BeautifulSoup import BeautifulSoup
+ except ImportError:
+ from beautifulsoup import BeautifulSoup
+ soup = BeautifulSoup(value)
+ return soup.findAll('a')
+ except ImportError:
+ if settings.DEBUG:
+ raise template.TemplateSyntaxError, "Error in 'get_links' filter: BeautifulSoup isn't installed."
+ return value
diff --git a/src/calibre/www/apps/blog/tests.py b/src/calibre/www/apps/blog/tests.py
new file mode 100644
index 0000000000..b4297c4ee8
--- /dev/null
+++ b/src/calibre/www/apps/blog/tests.py
@@ -0,0 +1,66 @@
+"""
+>>> from django.test import Client
+>>> from calibre.www.apps.blog.models import Post, Category
+>>> import datetime
+>>> from django.core.urlresolvers import reverse
+>>> client = Client()
+
+>>> category = Category(title='Django', slug='django')
+>>> category.save()
+>>> category2 = Category(title='Rails', slug='rails')
+>>> category2.save()
+
+>>> post = Post(title='DJ Ango', slug='dj-ango', body='Yo DJ! Turn that music up!', status=2, publish=datetime.datetime(2008,5,5,16,20))
+>>> post.save()
+
+>>> post2 = Post(title='Where my grails at?', slug='where', body='I Can haz Holy plez?', status=2, publish=datetime.datetime(2008,4,2,11,11))
+>>> post2.save()
+
+>>> post.categories.add(category)
+>>> post2.categories.add(category2)
+
+>>> response = client.get(reverse('blog_index'))
+>>> response.context[-1]['object_list']
+[, ]
+>>> response.status_code
+200
+
+>>> response = client.get(reverse('blog_category_list'))
+>>> response.context[-1]['object_list']
+[, ]
+>>> response.status_code
+200
+
+>>> response = client.get(category.get_absolute_url())
+>>> response.context[-1]['object_list']
+[]
+>>> response.status_code
+200
+
+>>> response = client.get(post.get_absolute_url())
+>>> response.context[-1]['object']
+
+>>> response.status_code
+200
+
+>>> response = client.get(reverse('blog_search'), {'q': 'DJ'})
+>>> response.context[-1]['object_list']
+[]
+>>> response.status_code
+200
+>>> response = client.get(reverse('blog_search'), {'q': 'Holy'})
+>>> response.context[-1]['object_list']
+[]
+>>> response.status_code
+200
+>>> response = client.get(reverse('blog_search'), {'q': ''})
+>>> response.context[-1]['message']
+'Search term was too vague. Please try again.'
+
+>>> response = client.get(reverse('blog_detail', args=[2008, 'apr', 2, 'where']))
+>>> response.context[-1]['object']
+
+>>> response.status_code
+200
+"""
+
diff --git a/src/calibre/www/apps/blog/urls.py b/src/calibre/www/apps/blog/urls.py
new file mode 100644
index 0000000000..222fc31e97
--- /dev/null
+++ b/src/calibre/www/apps/blog/urls.py
@@ -0,0 +1,41 @@
+from django.conf.urls.defaults import *
+from calibre.www.apps.blog import views as blog_views
+
+
+urlpatterns = patterns('',
+ url(r'^(?P\d{4})/(?P\w{3})/(?P\d{1,2})/(?P[-\w]+)/$',
+ view=blog_views.post_detail,
+ name='blog_detail'),
+
+ url(r'^(?P\d{4})/(?P\w{3})/(?P\d{1,2})/$',
+ view=blog_views.post_archive_day,
+ name='blog_archive_day'),
+
+ url(r'^(?P\d{4})/(?P\w{3})/$',
+ view=blog_views.post_archive_month,
+ name='blog_archive_month'),
+
+ url(r'^(?P\d{4})/$',
+ view=blog_views.post_archive_year,
+ name='blog_archive_year'),
+
+ url(r'^categories/(?P[-\w]+)/$',
+ view=blog_views.category_detail,
+ name='blog_category_detail'),
+
+ url (r'^categories/$',
+ view=blog_views.category_list,
+ name='blog_category_list'),
+
+ url (r'^search/$',
+ view=blog_views.search,
+ name='blog_search'),
+
+ url(r'^page/(?P\w)/$',
+ view=blog_views.post_list,
+ name='blog_index_paginated'),
+
+ url(r'^$',
+ view=blog_views.post_list,
+ name='blog_index'),
+)
\ No newline at end of file
diff --git a/src/calibre/www/apps/blog/views.py b/src/calibre/www/apps/blog/views.py
new file mode 100644
index 0000000000..2341140b36
--- /dev/null
+++ b/src/calibre/www/apps/blog/views.py
@@ -0,0 +1,160 @@
+from django.shortcuts import render_to_response, get_object_or_404
+from django.template import RequestContext
+from django.http import Http404
+from django.views.generic import date_based, list_detail
+from django.db.models import Q
+from calibre.www.apps.blog.models import *
+
+import datetime
+import re
+
+
+def post_list(request, page=0, **kwargs):
+ return list_detail.object_list(
+ request,
+ queryset = Post.objects.published(),
+ paginate_by = 20,
+ page = page,
+ **kwargs
+ )
+post_list.__doc__ = list_detail.object_list.__doc__
+
+
+def post_archive_year(request, year, **kwargs):
+ return date_based.archive_year(
+ request,
+ year = year,
+ date_field = 'publish',
+ queryset = Post.objects.published(),
+ make_object_list = True,
+ **kwargs
+ )
+post_archive_year.__doc__ = date_based.archive_year.__doc__
+
+
+def post_archive_month(request, year, month, **kwargs):
+ return date_based.archive_month(
+ request,
+ year = year,
+ month = month,
+ date_field = 'publish',
+ queryset = Post.objects.published(),
+ **kwargs
+ )
+post_archive_month.__doc__ = date_based.archive_month.__doc__
+
+
+def post_archive_day(request, year, month, day, **kwargs):
+ return date_based.archive_day(
+ request,
+ year = year,
+ month = month,
+ day = day,
+ date_field = 'publish',
+ queryset = Post.objects.published(),
+ **kwargs
+ )
+post_archive_day.__doc__ = date_based.archive_day.__doc__
+
+
+def post_detail(request, slug, year, month, day, **kwargs):
+ return date_based.object_detail(
+ request,
+ year = year,
+ month = month,
+ day = day,
+ date_field = 'publish',
+ slug = slug,
+ queryset = Post.objects.published(),
+ **kwargs
+ )
+post_detail.__doc__ = date_based.object_detail.__doc__
+
+
+def category_list(request, template_name = 'blog/category_list.html', **kwargs):
+ """
+ Category list
+
+ Template: ``blog/category_list.html``
+ Context:
+ object_list
+ List of categories.
+ """
+ return list_detail.object_list(
+ request,
+ queryset = Category.objects.all(),
+ template_name = template_name,
+ **kwargs
+ )
+
+def category_detail(request, slug, template_name = 'blog/category_detail.html', **kwargs):
+ """
+ Category detail
+
+ Template: ``blog/category_detail.html``
+ Context:
+ object_list
+ List of posts specific to the given category.
+ category
+ Given category.
+ """
+ category = get_object_or_404(Category, slug__iexact=slug)
+
+ return list_detail.object_list(
+ request,
+ queryset = category.post_set.published(),
+ extra_context = {'category': category},
+ template_name = template_name,
+ **kwargs
+ )
+
+
+# Stop Words courtesy of http://www.dcs.gla.ac.uk/idom/ir_resources/linguistic_utils/stop_words
+STOP_WORDS = r"""\b(a|about|above|across|after|afterwards|again|against|all|almost|alone|along|already|also|
+although|always|am|among|amongst|amoungst|amount|an|and|another|any|anyhow|anyone|anything|anyway|anywhere|are|
+around|as|at|back|be|became|because|become|becomes|becoming|been|before|beforehand|behind|being|below|beside|
+besides|between|beyond|bill|both|bottom|but|by|call|can|cannot|cant|co|computer|con|could|couldnt|cry|de|describe|
+detail|do|done|down|due|during|each|eg|eight|either|eleven|else|elsewhere|empty|enough|etc|even|ever|every|everyone|
+everything|everywhere|except|few|fifteen|fify|fill|find|fire|first|five|for|former|formerly|forty|found|four|from|
+front|full|further|get|give|go|had|has|hasnt|have|he|hence|her|here|hereafter|hereby|herein|hereupon|hers|herself|
+him|himself|his|how|however|hundred|i|ie|if|in|inc|indeed|interest|into|is|it|its|itself|keep|last|latter|latterly|
+least|less|ltd|made|many|may|me|meanwhile|might|mill|mine|more|moreover|most|mostly|move|much|must|my|myself|name|
+namely|neither|never|nevertheless|next|nine|no|nobody|none|noone|nor|not|nothing|now|nowhere|of|off|often|on|once|
+one|only|onto|or|other|others|otherwise|our|ours|ourselves|out|over|own|part|per|perhaps|please|put|rather|re|same|
+see|seem|seemed|seeming|seems|serious|several|she|should|show|side|since|sincere|six|sixty|so|some|somehow|someone|
+something|sometime|sometimes|somewhere|still|such|system|take|ten|than|that|the|their|them|themselves|then|thence|
+there|thereafter|thereby|therefore|therein|thereupon|these|they|thick|thin|third|this|those|though|three|through|
+throughout|thru|thus|to|together|too|top|toward|towards|twelve|twenty|two|un|under|until|up|upon|us|very|via|was|
+we|well|were|what|whatever|when|whence|whenever|where|whereafter|whereas|whereby|wherein|whereupon|wherever|whether|
+which|while|whither|who|whoever|whole|whom|whose|why|will|with|within|without|would|yet|you|your|yours|yourself|
+yourselves)\b"""
+
+
+def search(request, template_name='blog/post_search.html'):
+ """
+ Search for blog posts.
+
+ This template will allow you to setup a simple search form that will try to return results based on
+ given search strings. The queries will be put through a stop words filter to remove words like
+ 'the', 'a', or 'have' to help imporve the result set.
+
+ Template: ``blog/post_search.html``
+ Context:
+ object_list
+ List of blog posts that match given search term(s).
+ search_term
+ Given search term.
+ """
+ context = {}
+ if request.GET:
+ stop_word_list = re.compile(STOP_WORDS, re.IGNORECASE)
+ search_term = '%s' % request.GET['q']
+ cleaned_search_term = stop_word_list.sub('', search_term)
+ cleaned_search_term = cleaned_search_term.strip()
+ if len(cleaned_search_term) != 0:
+ post_list = Post.objects.published().filter(Q(body__icontains=cleaned_search_term) | Q(tags__icontains=cleaned_search_term) | Q(categories__title__icontains=cleaned_search_term))
+ context = {'object_list': post_list, 'search_term':search_term}
+ else:
+ message = 'Search term was too vague. Please try again.'
+ context = {'message':message}
+ return render_to_response(template_name, context, context_instance=RequestContext(request))
diff --git a/src/calibre/www/apps/inlines/CHANGELOG.yml b/src/calibre/www/apps/inlines/CHANGELOG.yml
new file mode 100644
index 0000000000..afd75c7a3b
--- /dev/null
+++ b/src/calibre/www/apps/inlines/CHANGELOG.yml
@@ -0,0 +1,10 @@
+changes:
+ date: 2008-05-18
+ change: Converted everything to 4 space tabs and made a few other changes to comply with Python Style Guide.
+
+ date: 2008-04-23
+ change: Added a mode called InlineType and a template tag that returns this models objects.
+
+ date: 2008-04-22
+ change: Added an 'extract_inlines' filter so you can loop over a list of inlines in a body of text.
+ change: Creating new inlines app.
\ No newline at end of file
diff --git a/src/calibre/www/apps/inlines/README.txt b/src/calibre/www/apps/inlines/README.txt
new file mode 100644
index 0000000000..649ef25f75
--- /dev/null
+++ b/src/calibre/www/apps/inlines/README.txt
@@ -0,0 +1,27 @@
+==============================================
+Django Basic Inlines
+http://code.google.com/p/django-basic-apps/
+==============================================
+
+A simple book library application for Django projects.
+
+To install this app, simply create a folder somewhere in
+your PYTHONPATH named 'basic' and place the 'inlines'
+app inside. Then add 'basic.inlines' to your projects
+INSTALLED_APPS list in your settings.py file.
+
+Inlines is a template filter that can be used in
+conjunction with inline markup to insert content objects
+into other pieces of content. An example would be inserting
+a photo into a blog post body.
+
+An example of the markup is:
+
+
+The type attribute is app_name.model_name and the id is
+the object id. Pretty simple.
+
+In your template you would say:
+ {% load inlines %}
+
+ {{ post.body|render_inlines }}
\ No newline at end of file
diff --git a/src/calibre/www/apps/inlines/__init__.py b/src/calibre/www/apps/inlines/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/calibre/www/apps/inlines/admin.py b/src/calibre/www/apps/inlines/admin.py
new file mode 100644
index 0000000000..35bf096e50
--- /dev/null
+++ b/src/calibre/www/apps/inlines/admin.py
@@ -0,0 +1,5 @@
+from django.contrib import admin
+from calibre.www.apps.inlines.models import *
+
+
+admin.site.register(InlineType)
\ No newline at end of file
diff --git a/src/calibre/www/apps/inlines/models.py b/src/calibre/www/apps/inlines/models.py
new file mode 100644
index 0000000000..e5308f2919
--- /dev/null
+++ b/src/calibre/www/apps/inlines/models.py
@@ -0,0 +1,17 @@
+from django.db import models
+from django.contrib.contenttypes.models import ContentType
+
+
+class InlineType(models.Model):
+ """ InlineType model """
+ title = models.CharField(max_length=200)
+ content_type = models.ForeignKey(ContentType)
+
+ class Meta:
+ db_table = 'inline_types'
+
+ class Admin:
+ pass
+
+ def __unicode__(self):
+ return self.title
\ No newline at end of file
diff --git a/src/calibre/www/apps/inlines/parser.py b/src/calibre/www/apps/inlines/parser.py
new file mode 100644
index 0000000000..bf2e959a61
--- /dev/null
+++ b/src/calibre/www/apps/inlines/parser.py
@@ -0,0 +1,92 @@
+from django.template import TemplateSyntaxError
+from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
+from django.http import Http404
+from django.utils.encoding import smart_unicode
+from django.template.loader import render_to_string
+from django.utils.safestring import mark_safe
+
+
+def inlines(value, return_list=False):
+ try:
+ from BeautifulSoup import BeautifulStoneSoup
+ except ImportError:
+ from beautifulsoup import BeautifulStoneSoup
+
+ content = BeautifulStoneSoup(value, selfClosingTags=['inline','img','br','input','meta','link','hr'])
+ inline_list = []
+
+ if return_list:
+ for inline in content.findAll('inline'):
+ rendered_inline = render_inline(inline)
+ inline_list.append(rendered_inline['context'])
+ return inline_list
+ else:
+ for inline in content.findAll('inline'):
+ rendered_inline = render_inline(inline)
+ inline.replaceWith(render_to_string(rendered_inline['template'], rendered_inline['context']))
+ return mark_safe(content)
+
+
+def render_inline(inline):
+ """
+ Replace inline markup with template markup that matches the
+ appropriate app and model.
+
+ """
+
+ # Look for inline type, 'app.model'
+ try:
+ app_label, model_name = inline['type'].split('.')
+ except:
+ if settings.DEBUG:
+ raise TemplateSyntaxError, "Couldn't find the attribute 'type' in the tag."
+ else:
+ return ''
+
+ # Look for content type
+ try:
+ content_type = ContentType.objects.get(app_label=app_label, model=model_name)
+ model = content_type.model_class()
+ except ContentType.DoesNotExist:
+ if settings.DEBUG:
+ raise TemplateSyntaxError, "Inline ContentType not found."
+ else:
+ return ''
+
+ # Check for an inline class attribute
+ try:
+ inline_class = smart_unicode(inline['class'])
+ except:
+ inline_class = ''
+
+ try:
+ try:
+ id_list = [int(i) for i in inline['ids'].split(',')]
+ obj_list = model.objects.in_bulk(id_list)
+ obj_list = list(obj_list[int(i)] for i in id_list)
+ context = { 'object_list': obj_list, 'class': inline_class }
+ except ValueError:
+ if settings.DEBUG:
+ raise ValueError, "The ids attribute is missing or invalid."
+ else:
+ return ''
+ except KeyError:
+ try:
+ obj = model.objects.get(pk=inline['id'])
+ context = { 'content_type':"%s.%s" % (app_label, model_name), 'object': obj, 'class': inline_class, 'settings': settings }
+ except model.DoesNotExist:
+ if settings.DEBUG:
+ raise model.DoesNotExist, "Object matching '%s' does not exist"
+ else:
+ return ''
+ except:
+ if settings.DEBUG:
+ raise TemplateSyntaxError, "The id attribute is missing or invalid."
+ else:
+ return ''
+
+ template = ["inlines/%s_%s.html" % (app_label, model_name), "inlines/default.html"]
+ rendered_inline = {'template':template, 'context':context}
+
+ return rendered_inline
\ No newline at end of file
diff --git a/src/calibre/www/apps/inlines/templatetags/__init__.py b/src/calibre/www/apps/inlines/templatetags/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/calibre/www/apps/inlines/templatetags/inlines.py b/src/calibre/www/apps/inlines/templatetags/inlines.py
new file mode 100644
index 0000000000..33a3196901
--- /dev/null
+++ b/src/calibre/www/apps/inlines/templatetags/inlines.py
@@ -0,0 +1,81 @@
+from django import template
+from calibre.www.apps.inlines.parser import inlines
+from calibre.www.apps.inlines.models import InlineType
+import re
+
+register = template.Library()
+
+
+@register.filter
+def render_inlines(value):
+ """
+ Renders inlines in a ``Post`` by passing them through inline templates.
+
+ Template Syntax::
+
+ {{ post.body|render_inlines|markdown:"safe" }}
+
+ Inline Syntax (singular)::
+
+
+
+ Inline Syntax (plural)::
+
+
+
+ An inline template will be used to render the inline. Templates will be
+ locaed in the following maner:
+
+ ``inlines/_.html``
+
+ The template will be passed the following context:
+
+ ``object``
+ An object for the corresponding passed id.
+
+ or
+
+ ``object_list``
+ A list of objects for the corresponding ids.
+
+ It would be wise to anticipate both object_list and object unless
+ you know for sure one or the other will only be present.
+ """
+ return inlines(value)
+
+@register.filter
+def extract_inlines(value):
+ return inlines(value, True)
+
+
+class InlineTypes(template.Node):
+ def __init__(self, var_name):
+ self.var_name = var_name
+
+ def render(self, context):
+ types = InlineType.objects.all()
+ context[self.var_name] = types
+ return ''
+
+@register.tag(name='get_inline_types')
+def do_get_inline_types(parser, token):
+ """
+ Gets all inline types.
+
+ Syntax::
+
+ {% get_inline_types as [var_name] %}
+
+ Example usage::
+
+ {% get_inline_types as inline_list %}
+ """
+ try:
+ tag_name, arg = token.contents.split(None, 1)
+ except ValueError:
+ raise template.TemplateSyntaxError, "%s tag requires arguments" % token.contents.split()[0]
+ m = re.search(r'as (\w+)', arg)
+ if not m:
+ raise template.TemplateSyntaxError, "%s tag had invalid arguments" % tag_name
+ var_name = m.groups()[0]
+ return InlineTypes(var_name)
\ No newline at end of file
diff --git a/src/calibre/www/apps/tagging/__init__.py b/src/calibre/www/apps/tagging/__init__.py
new file mode 100644
index 0000000000..9cca99b771
--- /dev/null
+++ b/src/calibre/www/apps/tagging/__init__.py
@@ -0,0 +1,30 @@
+from django.utils.translation import ugettext as _
+
+from calibre.www.apps.tagging.managers import ModelTaggedItemManager, TagDescriptor
+
+VERSION = (0, 3, 'pre')
+
+class AlreadyRegistered(Exception):
+ """
+ An attempt was made to register a model more than once.
+ """
+ pass
+
+registry = []
+
+def register(model, tag_descriptor_attr='tags',
+ tagged_item_manager_attr='tagged'):
+ """
+ Sets the given model class up for working with tags.
+ """
+ if model in registry:
+ raise AlreadyRegistered(
+ _('The model %s has already been registered.') % model.__name__)
+ registry.append(model)
+
+ # Add tag descriptor
+ setattr(model, tag_descriptor_attr, TagDescriptor())
+
+ # Add custom manager
+ ModelTaggedItemManager().contribute_to_class(model,
+ tagged_item_manager_attr)
diff --git a/src/calibre/www/apps/tagging/admin.py b/src/calibre/www/apps/tagging/admin.py
new file mode 100644
index 0000000000..51cb472e50
--- /dev/null
+++ b/src/calibre/www/apps/tagging/admin.py
@@ -0,0 +1,5 @@
+from django.contrib import admin
+from calibre.www.apps.tagging.models import Tag, TaggedItem
+
+admin.site.register(TaggedItem)
+admin.site.register(Tag)
diff --git a/src/calibre/www/apps/tagging/fields.py b/src/calibre/www/apps/tagging/fields.py
new file mode 100644
index 0000000000..f6bb9fce15
--- /dev/null
+++ b/src/calibre/www/apps/tagging/fields.py
@@ -0,0 +1,107 @@
+"""
+A custom Model Field for tagging.
+"""
+from django.db.models import signals
+from django.db.models.fields import CharField
+from django.utils.translation import ugettext_lazy as _
+
+from calibre.www.apps.tagging import settings
+from calibre.www.apps.tagging.models import Tag
+from calibre.www.apps.tagging.utils import edit_string_for_tags
+
+class TagField(CharField):
+ """
+ A "special" character field that actually works as a relationship to tags
+ "under the hood". This exposes a space-separated string of tags, but does
+ the splitting/reordering/etc. under the hood.
+ """
+ def __init__(self, *args, **kwargs):
+ kwargs['max_length'] = kwargs.get('max_length', 255)
+ kwargs['blank'] = kwargs.get('blank', True)
+ super(TagField, self).__init__(*args, **kwargs)
+
+ def contribute_to_class(self, cls, name):
+ super(TagField, self).contribute_to_class(cls, name)
+
+ # Make this object the descriptor for field access.
+ setattr(cls, self.name, self)
+
+ # Save tags back to the database post-save
+ signals.post_save.connect(self._save, cls, True)
+
+ def __get__(self, instance, owner=None):
+ """
+ Tag getter. Returns an instance's tags if accessed on an instance, and
+ all of a model's tags if called on a class. That is, this model::
+
+ class Link(models.Model):
+ ...
+ tags = TagField()
+
+ Lets you do both of these::
+
+ >>> l = Link.objects.get(...)
+ >>> l.tags
+ 'tag1 tag2 tag3'
+
+ >>> Link.tags
+ 'tag1 tag2 tag3 tag4'
+
+ """
+ # Handle access on the model (i.e. Link.tags)
+ if instance is None:
+ return edit_string_for_tags(Tag.objects.usage_for_model(owner))
+
+ tags = self._get_instance_tag_cache(instance)
+ if tags is None:
+ if instance.pk is None:
+ self._set_instance_tag_cache(instance, '')
+ else:
+ self._set_instance_tag_cache(
+ instance, edit_string_for_tags(Tag.objects.get_for_object(instance)))
+ return self._get_instance_tag_cache(instance)
+
+ def __set__(self, instance, value):
+ """
+ Set an object's tags.
+ """
+ if instance is None:
+ raise AttributeError(_('%s can only be set on instances.') % self.name)
+ if settings.FORCE_LOWERCASE_TAGS and value is not None:
+ value = value.lower()
+ self._set_instance_tag_cache(instance, value)
+
+ def _save(self, **kwargs): #signal, sender, instance):
+ """
+ Save tags back to the database
+ """
+ tags = self._get_instance_tag_cache(kwargs['instance'])
+ if tags is not None:
+ Tag.objects.update_tags(kwargs['instance'], tags)
+
+ def __delete__(self, instance):
+ """
+ Clear all of an object's tags.
+ """
+ self._set_instance_tag_cache(instance, '')
+
+ def _get_instance_tag_cache(self, instance):
+ """
+ Helper: get an instance's tag cache.
+ """
+ return getattr(instance, '_%s_cache' % self.attname, None)
+
+ def _set_instance_tag_cache(self, instance, tags):
+ """
+ Helper: set an instance's tag cache.
+ """
+ setattr(instance, '_%s_cache' % self.attname, tags)
+
+ def get_internal_type(self):
+ return 'CharField'
+
+ def formfield(self, **kwargs):
+ from calibre.www.apps.tagging import forms
+ defaults = {'form_class': forms.TagField}
+ defaults.update(kwargs)
+ return super(TagField, self).formfield(**defaults)
diff --git a/src/calibre/www/apps/tagging/forms.py b/src/calibre/www/apps/tagging/forms.py
new file mode 100644
index 0000000000..997ff5c66b
--- /dev/null
+++ b/src/calibre/www/apps/tagging/forms.py
@@ -0,0 +1,40 @@
+"""
+Tagging components for Django's form library.
+"""
+from django import forms
+from django.utils.translation import ugettext as _
+
+from calibre.www.apps.tagging import settings
+from calibre.www.apps.tagging.models import Tag
+from calibre.www.apps.tagging.utils import parse_tag_input
+
+class AdminTagForm(forms.ModelForm):
+ class Meta:
+ model = Tag
+
+ def clean_name(self):
+ value = self.cleaned_data['name']
+ tag_names = parse_tag_input(value)
+ if len(tag_names) > 1:
+ raise ValidationError(_('Multiple tags were given.'))
+ elif len(tag_names[0]) > settings.MAX_TAG_LENGTH:
+ raise forms.ValidationError(
+ _('A tag may be no more than %s characters long.') %
+ settings.MAX_TAG_LENGTH)
+ return value
+
+class TagField(forms.CharField):
+ """
+ A ``CharField`` which validates that its input is a valid list of
+ tag names.
+ """
+ def clean(self, value):
+ value = super(TagField, self).clean(value)
+ if value == u'':
+ return value
+ for tag_name in parse_tag_input(value):
+ if len(tag_name) > settings.MAX_TAG_LENGTH:
+ raise forms.ValidationError(
+ _('Each tag may be no more than %s characters long.') %
+ settings.MAX_TAG_LENGTH)
+ return value
diff --git a/src/calibre/www/apps/tagging/generic.py b/src/calibre/www/apps/tagging/generic.py
new file mode 100644
index 0000000000..75d1b8e04e
--- /dev/null
+++ b/src/calibre/www/apps/tagging/generic.py
@@ -0,0 +1,40 @@
+from django.contrib.contenttypes.models import ContentType
+
+def fetch_content_objects(tagged_items, select_related_for=None):
+ """
+ Retrieves ``ContentType`` and content objects for the given list of
+ ``TaggedItems``, grouping the retrieval of content objects by model
+ type to reduce the number of queries executed.
+
+ This results in ``number_of_content_types + 1`` queries rather than
+ the ``number_of_tagged_items * 2`` queries you'd get by iterating
+ over the list and accessing each item's ``object`` attribute.
+
+ A ``select_related_for`` argument can be used to specify a list of
+ of model names (corresponding to the ``model`` field of a
+ ``ContentType``) for which ``select_related`` should be used when
+ retrieving model instances.
+ """
+ if select_related_for is None: select_related_for = []
+
+ # Group content object pks by their content type pks
+ objects = {}
+ for item in tagged_items:
+ objects.setdefault(item.content_type_id, []).append(item.object_id)
+
+ # Retrieve content types and content objects in bulk
+ content_types = ContentType._default_manager.in_bulk(objects.keys())
+ for content_type_pk, object_pks in objects.iteritems():
+ model = content_types[content_type_pk].model_class()
+ if content_types[content_type_pk].model in select_related_for:
+ objects[content_type_pk] = model._default_manager.select_related().in_bulk(object_pks)
+ else:
+ objects[content_type_pk] = model._default_manager.in_bulk(object_pks)
+
+ # Set content types and content objects in the appropriate cache
+ # attributes, so accessing the 'content_type' and 'object'
+ # attributes on each tagged item won't result in further database
+ # hits.
+ for item in tagged_items:
+ item._object_cache = objects[item.content_type_id][item.object_id]
+ item._content_type_cache = content_types[item.content_type_id]
diff --git a/src/calibre/www/apps/tagging/managers.py b/src/calibre/www/apps/tagging/managers.py
new file mode 100644
index 0000000000..b17269e146
--- /dev/null
+++ b/src/calibre/www/apps/tagging/managers.py
@@ -0,0 +1,68 @@
+"""
+Custom managers for Django models registered with the tagging
+application.
+"""
+from django.contrib.contenttypes.models import ContentType
+from django.db import models
+
+from calibre.www.apps.tagging.models import Tag, TaggedItem
+
+class ModelTagManager(models.Manager):
+ """
+ A manager for retrieving tags for a particular model.
+ """
+ def get_query_set(self):
+ ctype = ContentType.objects.get_for_model(self.model)
+ return Tag.objects.filter(
+ items__content_type__pk=ctype.pk).distinct()
+
+ def cloud(self, *args, **kwargs):
+ return Tag.objects.cloud_for_model(self.model, *args, **kwargs)
+
+ def related(self, tags, *args, **kwargs):
+ return Tag.objects.related_for_model(tags, self.model, *args, **kwargs)
+
+ def usage(self, *args, **kwargs):
+ return Tag.objects.usage_for_model(self.model, *args, **kwargs)
+
+class ModelTaggedItemManager(models.Manager):
+ """
+ A manager for retrieving model instances based on their tags.
+ """
+ def related_to(self, obj, queryset=None, num=None):
+ if queryset is None:
+ return TaggedItem.objects.get_related(obj, self.model, num=num)
+ else:
+ return TaggedItem.objects.get_related(obj, queryset, num=num)
+
+ def with_all(self, tags, queryset=None):
+ if queryset is None:
+ return TaggedItem.objects.get_by_model(self.model, tags)
+ else:
+ return TaggedItem.objects.get_by_model(queryset, tags)
+
+ def with_any(self, tags, queryset=None):
+ if queryset is None:
+ return TaggedItem.objects.get_union_by_model(self.model, tags)
+ else:
+ return TaggedItem.objects.get_union_by_model(queryset, tags)
+
+class TagDescriptor(object):
+ """
+ A descriptor which provides access to a ``ModelTagManager`` for
+ model classes and simple retrieval, updating and deletion of tags
+ for model instances.
+ """
+ def __get__(self, instance, owner):
+ if not instance:
+ tag_manager = ModelTagManager()
+ tag_manager.model = owner
+ return tag_manager
+ else:
+ return Tag.objects.get_for_object(instance)
+
+ def __set__(self, instance, value):
+ Tag.objects.update_tags(instance, value)
+
+ def __delete__(self, instance):
+ Tag.objects.update_tags(instance, None)
diff --git a/src/calibre/www/apps/tagging/models.py b/src/calibre/www/apps/tagging/models.py
new file mode 100644
index 0000000000..59fe7682c4
--- /dev/null
+++ b/src/calibre/www/apps/tagging/models.py
@@ -0,0 +1,480 @@
+"""
+Models and managers for generic tagging.
+"""
+# Python 2.3 compatibility
+try:
+ set
+except NameError:
+ from sets import Set as set
+
+from django.contrib.contenttypes import generic
+from django.contrib.contenttypes.models import ContentType
+from django.db import connection, models
+from django.db.models.query import QuerySet
+from django.utils.translation import ugettext_lazy as _
+
+from calibre.www.apps.tagging import settings
+from calibre.www.apps.tagging.utils import calculate_cloud, get_tag_list, get_queryset_and_model, parse_tag_input
+from calibre.www.apps.tagging.utils import LOGARITHMIC
+
+qn = connection.ops.quote_name
+
+############
+# Managers #
+############
+
+class TagManager(models.Manager):
+ def update_tags(self, obj, tag_names):
+ """
+ Update tags associated with an object.
+ """
+ ctype = ContentType.objects.get_for_model(obj)
+ current_tags = list(self.filter(items__content_type__pk=ctype.pk,
+ items__object_id=obj.pk))
+ updated_tag_names = parse_tag_input(tag_names)
+ if settings.FORCE_LOWERCASE_TAGS:
+ updated_tag_names = [t.lower() for t in updated_tag_names]
+
+ # Remove tags which no longer apply
+ tags_for_removal = [tag for tag in current_tags \
+ if tag.name not in updated_tag_names]
+ if len(tags_for_removal):
+ TaggedItem._default_manager.filter(content_type__pk=ctype.pk,
+ object_id=obj.pk,
+ tag__in=tags_for_removal).delete()
+ # Add new tags
+ current_tag_names = [tag.name for tag in current_tags]
+ for tag_name in updated_tag_names:
+ if tag_name not in current_tag_names:
+ tag, created = self.get_or_create(name=tag_name)
+ TaggedItem._default_manager.create(tag=tag, object=obj)
+
+ def add_tag(self, obj, tag_name):
+ """
+ Associates the given object with a tag.
+ """
+ tag_names = parse_tag_input(tag_name)
+ if not len(tag_names):
+ raise AttributeError(_('No tags were given: "%s".') % tag_name)
+ if len(tag_names) > 1:
+ raise AttributeError(_('Multiple tags were given: "%s".') % tag_name)
+ tag_name = tag_names[0]
+ if settings.FORCE_LOWERCASE_TAGS:
+ tag_name = tag_name.lower()
+ tag, created = self.get_or_create(name=tag_name)
+ ctype = ContentType.objects.get_for_model(obj)
+ TaggedItem._default_manager.get_or_create(
+ tag=tag, content_type=ctype, object_id=obj.pk)
+
+ def get_for_object(self, obj):
+ """
+ Create a queryset matching all tags associated with the given
+ object.
+ """
+ ctype = ContentType.objects.get_for_model(obj)
+ return self.filter(items__content_type__pk=ctype.pk,
+ items__object_id=obj.pk)
+
+ def _get_usage(self, model, counts=False, min_count=None, extra_joins=None, extra_criteria=None, params=None):
+ """
+ Perform the custom SQL query for ``usage_for_model`` and
+ ``usage_for_queryset``.
+ """
+ if min_count is not None: counts = True
+
+ model_table = qn(model._meta.db_table)
+ model_pk = '%s.%s' % (model_table, qn(model._meta.pk.column))
+ query = """
+ SELECT DISTINCT %(tag)s.id, %(tag)s.name%(count_sql)s
+ FROM
+ %(tag)s
+ INNER JOIN %(tagged_item)s
+ ON %(tag)s.id = %(tagged_item)s.tag_id
+ INNER JOIN %(model)s
+ ON %(tagged_item)s.object_id = %(model_pk)s
+ %%s
+ WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
+ %%s
+ GROUP BY %(tag)s.id, %(tag)s.name
+ %%s
+ ORDER BY %(tag)s.name ASC""" % {
+ 'tag': qn(self.model._meta.db_table),
+ 'count_sql': counts and (', COUNT(%s)' % model_pk) or '',
+ 'tagged_item': qn(TaggedItem._meta.db_table),
+ 'model': model_table,
+ 'model_pk': model_pk,
+ 'content_type_id': ContentType.objects.get_for_model(model).pk,
+ }
+
+ min_count_sql = ''
+ if min_count is not None:
+ min_count_sql = 'HAVING COUNT(%s) >= %%s' % model_pk
+ params.append(min_count)
+
+ cursor = connection.cursor()
+ cursor.execute(query % (extra_joins, extra_criteria, min_count_sql), params)
+ tags = []
+ for row in cursor.fetchall():
+ t = self.model(*row[:2])
+ if counts:
+ t.count = row[2]
+ tags.append(t)
+ return tags
+
+ def usage_for_model(self, model, counts=False, min_count=None, filters=None):
+ """
+ Obtain a list of tags associated with instances of the given
+ Model class.
+
+ If ``counts`` is True, a ``count`` attribute will be added to
+ each tag, indicating how many times it has been used against
+ the Model class in question.
+
+ If ``min_count`` is given, only tags which have a ``count``
+ greater than or equal to ``min_count`` will be returned.
+ Passing a value for ``min_count`` implies ``counts=True``.
+
+ To limit the tags (and counts, if specified) returned to those
+ used by a subset of the Model's instances, pass a dictionary
+ of field lookups to be applied to the given Model as the
+ ``filters`` argument.
+ """
+ if filters is None: filters = {}
+
+ queryset = model._default_manager.filter()
+ for f in filters.items():
+ queryset.query.add_filter(f)
+ usage = self.usage_for_queryset(queryset, counts, min_count)
+
+ return usage
+
+ def usage_for_queryset(self, queryset, counts=False, min_count=None):
+ """
+ Obtain a list of tags associated with instances of a model
+ contained in the given queryset.
+
+ If ``counts`` is True, a ``count`` attribute will be added to
+ each tag, indicating how many times it has been used against
+ the Model class in question.
+
+ If ``min_count`` is given, only tags which have a ``count``
+ greater than or equal to ``min_count`` will be returned.
+ Passing a value for ``min_count`` implies ``counts=True``.
+ """
+
+ extra_joins = ' '.join(queryset.query.get_from_clause()[0][1:])
+ where, params = queryset.query.where.as_sql()
+ if where:
+ extra_criteria = 'AND %s' % where
+ else:
+ extra_criteria = ''
+ return self._get_usage(queryset.model, counts, min_count, extra_joins, extra_criteria, params)
+
+ def related_for_model(self, tags, model, counts=False, min_count=None):
+ """
+ Obtain a list of tags related to a given list of tags - that
+ is, other tags used by items which have all the given tags.
+
+ If ``counts`` is True, a ``count`` attribute will be added to
+ each tag, indicating the number of items which have it in
+ addition to the given list of tags.
+
+ If ``min_count`` is given, only tags which have a ``count``
+ greater than or equal to ``min_count`` will be returned.
+ Passing a value for ``min_count`` implies ``counts=True``.
+ """
+ if min_count is not None: counts = True
+ tags = get_tag_list(tags)
+ tag_count = len(tags)
+ tagged_item_table = qn(TaggedItem._meta.db_table)
+ query = """
+ SELECT %(tag)s.id, %(tag)s.name%(count_sql)s
+ FROM %(tagged_item)s INNER JOIN %(tag)s ON %(tagged_item)s.tag_id = %(tag)s.id
+ WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
+ AND %(tagged_item)s.object_id IN
+ (
+ SELECT %(tagged_item)s.object_id
+ FROM %(tagged_item)s, %(tag)s
+ WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
+ AND %(tag)s.id = %(tagged_item)s.tag_id
+ AND %(tag)s.id IN (%(tag_id_placeholders)s)
+ GROUP BY %(tagged_item)s.object_id
+ HAVING COUNT(%(tagged_item)s.object_id) = %(tag_count)s
+ )
+ AND %(tag)s.id NOT IN (%(tag_id_placeholders)s)
+ GROUP BY %(tag)s.id, %(tag)s.name
+ %(min_count_sql)s
+ ORDER BY %(tag)s.name ASC""" % {
+ 'tag': qn(self.model._meta.db_table),
+ 'count_sql': counts and ', COUNT(%s.object_id)' % tagged_item_table or '',
+ 'tagged_item': tagged_item_table,
+ 'content_type_id': ContentType.objects.get_for_model(model).pk,
+ 'tag_id_placeholders': ','.join(['%s'] * tag_count),
+ 'tag_count': tag_count,
+ 'min_count_sql': min_count is not None and ('HAVING COUNT(%s.object_id) >= %%s' % tagged_item_table) or '',
+ }
+
+ params = [tag.pk for tag in tags] * 2
+ if min_count is not None:
+ params.append(min_count)
+
+ cursor = connection.cursor()
+ cursor.execute(query, params)
+ related = []
+ for row in cursor.fetchall():
+ tag = self.model(*row[:2])
+ if counts is True:
+ tag.count = row[2]
+ related.append(tag)
+ return related
+
+ def cloud_for_model(self, model, steps=4, distribution=LOGARITHMIC,
+ filters=None, min_count=None):
+ """
+ Obtain a list of tags associated with instances of the given
+ Model, giving each tag a ``count`` attribute indicating how
+ many times it has been used and a ``font_size`` attribute for
+ use in displaying a tag cloud.
+
+ ``steps`` defines the range of font sizes - ``font_size`` will
+ be an integer between 1 and ``steps`` (inclusive).
+
+ ``distribution`` defines the type of font size distribution
+ algorithm which will be used - logarithmic or linear. It must
+ be either ``tagging.utils.LOGARITHMIC`` or
+ ``tagging.utils.LINEAR``.
+
+ To limit the tags displayed in the cloud to those associated
+ with a subset of the Model's instances, pass a dictionary of
+ field lookups to be applied to the given Model as the
+ ``filters`` argument.
+
+ To limit the tags displayed in the cloud to those with a
+ ``count`` greater than or equal to ``min_count``, pass a value
+ for the ``min_count`` argument.
+ """
+ tags = list(self.usage_for_model(model, counts=True, filters=filters,
+ min_count=min_count))
+ return calculate_cloud(tags, steps, distribution)
+
+class TaggedItemManager(models.Manager):
+ """
+ FIXME There's currently no way to get the ``GROUP BY`` and ``HAVING``
+ SQL clauses required by many of this manager's methods into
+ Django's ORM.
+
+ For now, we manually execute a query to retrieve the PKs of
+ objects we're interested in, then use the ORM's ``__in``
+ lookup to return a ``QuerySet``.
+
+ Now that the queryset-refactor branch is in the trunk, this can be
+ tidied up significantly.
+ """
+ def get_by_model(self, queryset_or_model, tags):
+ """
+ Create a ``QuerySet`` containing instances of the specified
+ model associated with a given tag or list of tags.
+ """
+ tags = get_tag_list(tags)
+ tag_count = len(tags)
+ if tag_count == 0:
+ # No existing tags were given
+ queryset, model = get_queryset_and_model(queryset_or_model)
+ return model._default_manager.none()
+ elif tag_count == 1:
+ # Optimisation for single tag - fall through to the simpler
+ # query below.
+ tag = tags[0]
+ else:
+ return self.get_intersection_by_model(queryset_or_model, tags)
+
+ queryset, model = get_queryset_and_model(queryset_or_model)
+ content_type = ContentType.objects.get_for_model(model)
+ opts = self.model._meta
+ tagged_item_table = qn(opts.db_table)
+ return queryset.extra(
+ tables=[opts.db_table],
+ where=[
+ '%s.content_type_id = %%s' % tagged_item_table,
+ '%s.tag_id = %%s' % tagged_item_table,
+ '%s.%s = %s.object_id' % (qn(model._meta.db_table),
+ qn(model._meta.pk.column),
+ tagged_item_table)
+ ],
+ params=[content_type.pk, tag.pk],
+ )
+
+ def get_intersection_by_model(self, queryset_or_model, tags):
+ """
+ Create a ``QuerySet`` containing instances of the specified
+ model associated with *all* of the given list of tags.
+ """
+ tags = get_tag_list(tags)
+ tag_count = len(tags)
+ queryset, model = get_queryset_and_model(queryset_or_model)
+
+ if not tag_count:
+ return model._default_manager.none()
+
+ model_table = qn(model._meta.db_table)
+ # This query selects the ids of all objects which have all the
+ # given tags.
+ query = """
+ SELECT %(model_pk)s
+ FROM %(model)s, %(tagged_item)s
+ WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
+ AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s)
+ AND %(model_pk)s = %(tagged_item)s.object_id
+ GROUP BY %(model_pk)s
+ HAVING COUNT(%(model_pk)s) = %(tag_count)s""" % {
+ 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)),
+ 'model': model_table,
+ 'tagged_item': qn(self.model._meta.db_table),
+ 'content_type_id': ContentType.objects.get_for_model(model).pk,
+ 'tag_id_placeholders': ','.join(['%s'] * tag_count),
+ 'tag_count': tag_count,
+ }
+
+ cursor = connection.cursor()
+ cursor.execute(query, [tag.pk for tag in tags])
+ object_ids = [row[0] for row in cursor.fetchall()]
+ if len(object_ids) > 0:
+ return queryset.filter(pk__in=object_ids)
+ else:
+ return model._default_manager.none()
+
+ def get_union_by_model(self, queryset_or_model, tags):
+ """
+ Create a ``QuerySet`` containing instances of the specified
+ model associated with *any* of the given list of tags.
+ """
+ tags = get_tag_list(tags)
+ tag_count = len(tags)
+ queryset, model = get_queryset_and_model(queryset_or_model)
+
+ if not tag_count:
+ return model._default_manager.none()
+
+ model_table = qn(model._meta.db_table)
+ # This query selects the ids of all objects which have any of
+ # the given tags.
+ query = """
+ SELECT %(model_pk)s
+ FROM %(model)s, %(tagged_item)s
+ WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
+ AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s)
+ AND %(model_pk)s = %(tagged_item)s.object_id
+ GROUP BY %(model_pk)s""" % {
+ 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)),
+ 'model': model_table,
+ 'tagged_item': qn(self.model._meta.db_table),
+ 'content_type_id': ContentType.objects.get_for_model(model).pk,
+ 'tag_id_placeholders': ','.join(['%s'] * tag_count),
+ }
+
+ cursor = connection.cursor()
+ cursor.execute(query, [tag.pk for tag in tags])
+ object_ids = [row[0] for row in cursor.fetchall()]
+ if len(object_ids) > 0:
+ return queryset.filter(pk__in=object_ids)
+ else:
+ return model._default_manager.none()
+
+ def get_related(self, obj, queryset_or_model, num=None):
+ """
+ Retrieve a list of instances of the specified model which share
+ tags with the model instance ``obj``, ordered by the number of
+ shared tags in descending order.
+
+ If ``num`` is given, a maximum of ``num`` instances will be
+ returned.
+ """
+ queryset, model = get_queryset_and_model(queryset_or_model)
+ model_table = qn(model._meta.db_table)
+ content_type = ContentType.objects.get_for_model(obj)
+ related_content_type = ContentType.objects.get_for_model(model)
+ query = """
+ SELECT %(model_pk)s, COUNT(related_tagged_item.object_id) AS %(count)s
+ FROM %(model)s, %(tagged_item)s, %(tag)s, %(tagged_item)s related_tagged_item
+ WHERE %(tagged_item)s.object_id = %%s
+ AND %(tagged_item)s.content_type_id = %(content_type_id)s
+ AND %(tag)s.id = %(tagged_item)s.tag_id
+ AND related_tagged_item.content_type_id = %(related_content_type_id)s
+ AND related_tagged_item.tag_id = %(tagged_item)s.tag_id
+ AND %(model_pk)s = related_tagged_item.object_id"""
+ if content_type.pk == related_content_type.pk:
+ # Exclude the given instance itself if determining related
+ # instances for the same model.
+ query += """
+ AND related_tagged_item.object_id != %(tagged_item)s.object_id"""
+ query += """
+ GROUP BY %(model_pk)s
+ ORDER BY %(count)s DESC
+ %(limit_offset)s"""
+ query = query % {
+ 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)),
+ 'count': qn('count'),
+ 'model': model_table,
+ 'tagged_item': qn(self.model._meta.db_table),
+ 'tag': qn(self.model._meta.get_field('tag').rel.to._meta.db_table),
+ 'content_type_id': content_type.pk,
+ 'related_content_type_id': related_content_type.pk,
+ # Hardcoding this for now just to get tests working again - this
+ # should now be handled by the query object.
+ 'limit_offset': num is not None and 'LIMIT %s' or '',
+ }
+
+ cursor = connection.cursor()
+ params = [obj.pk]
+ if num is not None:
+ params.append(num)
+ cursor.execute(query, params)
+ object_ids = [row[0] for row in cursor.fetchall()]
+ if len(object_ids) > 0:
+ # Use in_bulk here instead of an id__in lookup, because id__in would
+ # clobber the ordering.
+ object_dict = queryset.in_bulk(object_ids)
+ return [object_dict[object_id] for object_id in object_ids \
+ if object_id in object_dict]
+ else:
+ return []
+
+##########
+# Models #
+##########
+
+class Tag(models.Model):
+ """
+ A tag.
+ """
+ name = models.CharField(_('name'), max_length=50, unique=True, db_index=True)
+
+ objects = TagManager()
+
+ class Meta:
+ ordering = ('name',)
+ verbose_name = _('tag')
+ verbose_name_plural = _('tags')
+
+ def __unicode__(self):
+ return self.name
+
+class TaggedItem(models.Model):
+ """
+ Holds the relationship between a tag and the item being tagged.
+ """
+ tag = models.ForeignKey(Tag, verbose_name=_('tag'), related_name='items')
+ content_type = models.ForeignKey(ContentType, verbose_name=_('content type'))
+ object_id = models.PositiveIntegerField(_('object id'), db_index=True)
+ object = generic.GenericForeignKey('content_type', 'object_id')
+
+ objects = TaggedItemManager()
+
+ class Meta:
+ # Enforce unique tag association per object
+ unique_together = (('tag', 'content_type', 'object_id'),)
+ verbose_name = _('tagged item')
+ verbose_name_plural = _('tagged items')
+
+ def __unicode__(self):
+ return u'%s [%s]' % (self.object, self.tag)
diff --git a/src/calibre/www/apps/tagging/settings.py b/src/calibre/www/apps/tagging/settings.py
new file mode 100644
index 0000000000..1d6224cd5d
--- /dev/null
+++ b/src/calibre/www/apps/tagging/settings.py
@@ -0,0 +1,13 @@
+"""
+Convenience module for access of custom tagging application settings,
+which enforces default settings when the main settings module does not
+contain the appropriate settings.
+"""
+from django.conf import settings
+
+# The maximum length of a tag's name.
+MAX_TAG_LENGTH = getattr(settings, 'MAX_TAG_LENGTH', 50)
+
+# Whether to force all tags to lowercase before they are saved to the
+# database.
+FORCE_LOWERCASE_TAGS = getattr(settings, 'FORCE_LOWERCASE_TAGS', False)
diff --git a/src/calibre/www/apps/tagging/templatetags/__init__.py b/src/calibre/www/apps/tagging/templatetags/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/calibre/www/apps/tagging/templatetags/tagging_tags.py b/src/calibre/www/apps/tagging/templatetags/tagging_tags.py
new file mode 100644
index 0000000000..11d31ccb4f
--- /dev/null
+++ b/src/calibre/www/apps/tagging/templatetags/tagging_tags.py
@@ -0,0 +1,231 @@
+from django.db.models import get_model
+from django.template import Library, Node, TemplateSyntaxError, Variable, resolve_variable
+from django.utils.translation import ugettext as _
+
+from tagging.models import Tag, TaggedItem
+from tagging.utils import LINEAR, LOGARITHMIC
+
+register = Library()
+
+class TagsForModelNode(Node):
+ def __init__(self, model, context_var, counts):
+ self.model = model
+ self.context_var = context_var
+ self.counts = counts
+
+ def render(self, context):
+ model = get_model(*self.model.split('.'))
+ if model is None:
+ raise TemplateSyntaxError(_('tags_for_model tag was given an invalid model: %s') % self.model)
+ context[self.context_var] = Tag.objects.usage_for_model(model, counts=self.counts)
+ return ''
+
+class TagCloudForModelNode(Node):
+ def __init__(self, model, context_var, **kwargs):
+ self.model = model
+ self.context_var = context_var
+ self.kwargs = kwargs
+
+ def render(self, context):
+ model = get_model(*self.model.split('.'))
+ if model is None:
+ raise TemplateSyntaxError(_('tag_cloud_for_model tag was given an invalid model: %s') % self.model)
+ context[self.context_var] = \
+ Tag.objects.cloud_for_model(model, **self.kwargs)
+ return ''
+
+class TagsForObjectNode(Node):
+ def __init__(self, obj, context_var):
+ self.obj = Variable(obj)
+ self.context_var = context_var
+
+ def render(self, context):
+ context[self.context_var] = \
+ Tag.objects.get_for_object(self.obj.resolve(context))
+ return ''
+
+class TaggedObjectsNode(Node):
+ def __init__(self, tag, model, context_var):
+ self.tag = Variable(tag)
+ self.context_var = context_var
+ self.model = model
+
+ def render(self, context):
+ model = get_model(*self.model.split('.'))
+ if model is None:
+ raise TemplateSyntaxError(_('tagged_objects tag was given an invalid model: %s') % self.model)
+ context[self.context_var] = \
+ TaggedItem.objects.get_by_model(model, self.tag.resolve(context))
+ return ''
+
+def do_tags_for_model(parser, token):
+ """
+ Retrieves a list of ``Tag`` objects associated with a given model
+ and stores them in a context variable.
+
+ Usage::
+
+ {% tags_for_model [model] as [varname] %}
+
+ The model is specified in ``[appname].[modelname]`` format.
+
+ Extended usage::
+
+ {% tags_for_model [model] as [varname] with counts %}
+
+ If specified - by providing extra ``with counts`` arguments - adds
+ a ``count`` attribute to each tag containing the number of
+ instances of the given model which have been tagged with it.
+
+ Examples::
+
+ {% tags_for_model products.Widget as widget_tags %}
+ {% tags_for_model products.Widget as widget_tags with counts %}
+
+ """
+ bits = token.contents.split()
+ len_bits = len(bits)
+ if len_bits not in (4, 6):
+ raise TemplateSyntaxError(_('%s tag requires either three or five arguments') % bits[0])
+ if bits[2] != 'as':
+ raise TemplateSyntaxError(_("second argument to %s tag must be 'as'") % bits[0])
+ if len_bits == 6:
+ if bits[4] != 'with':
+ raise TemplateSyntaxError(_("if given, fourth argument to %s tag must be 'with'") % bits[0])
+ if bits[5] != 'counts':
+ raise TemplateSyntaxError(_("if given, fifth argument to %s tag must be 'counts'") % bits[0])
+ if len_bits == 4:
+ return TagsForModelNode(bits[1], bits[3], counts=False)
+ else:
+ return TagsForModelNode(bits[1], bits[3], counts=True)
+
+def do_tag_cloud_for_model(parser, token):
+ """
+ Retrieves a list of ``Tag`` objects for a given model, with tag
+ cloud attributes set, and stores them in a context variable.
+
+ Usage::
+
+ {% tag_cloud_for_model [model] as [varname] %}
+
+ The model is specified in ``[appname].[modelname]`` format.
+
+ Extended usage::
+
+ {% tag_cloud_for_model [model] as [varname] with [options] %}
+
+ Extra options can be provided after an optional ``with`` argument,
+ with each option being specified in ``[name]=[value]`` format. Valid
+ extra options are:
+
+ ``steps``
+ Integer. Defines the range of font sizes.
+
+ ``min_count``
+ Integer. Defines the minimum number of times a tag must have
+ been used to appear in the cloud.
+
+ ``distribution``
+ One of ``linear`` or ``log``. Defines the font-size
+ distribution algorithm to use when generating the tag cloud.
+
+ Examples::
+
+ {% tag_cloud_for_model products.Widget as widget_tags %}
+ {% tag_cloud_for_model products.Widget as widget_tags with steps=9 min_count=3 distribution=log %}
+
+ """
+ bits = token.contents.split()
+ len_bits = len(bits)
+ if len_bits != 4 and len_bits not in range(6, 9):
+ raise TemplateSyntaxError(_('%s tag requires either three or between five and seven arguments') % bits[0])
+ if bits[2] != 'as':
+ raise TemplateSyntaxError(_("second argument to %s tag must be 'as'") % bits[0])
+ kwargs = {}
+ if len_bits > 5:
+ if bits[4] != 'with':
+ raise TemplateSyntaxError(_("if given, fourth argument to %s tag must be 'with'") % bits[0])
+ for i in range(5, len_bits):
+ try:
+ name, value = bits[i].split('=')
+ if name == 'steps' or name == 'min_count':
+ try:
+ kwargs[str(name)] = int(value)
+ except ValueError:
+ raise TemplateSyntaxError(_("%(tag)s tag's '%(option)s' option was not a valid integer: '%(value)s'") % {
+ 'tag': bits[0],
+ 'option': name,
+ 'value': value,
+ })
+ elif name == 'distribution':
+ if value in ['linear', 'log']:
+ kwargs[str(name)] = {'linear': LINEAR, 'log': LOGARITHMIC}[value]
+ else:
+ raise TemplateSyntaxError(_("%(tag)s tag's '%(option)s' option was not a valid choice: '%(value)s'") % {
+ 'tag': bits[0],
+ 'option': name,
+ 'value': value,
+ })
+ else:
+ raise TemplateSyntaxError(_("%(tag)s tag was given an invalid option: '%(option)s'") % {
+ 'tag': bits[0],
+ 'option': name,
+ })
+ except ValueError:
+ raise TemplateSyntaxError(_("%(tag)s tag was given a badly formatted option: '%(option)s'") % {
+ 'tag': bits[0],
+ 'option': bits[i],
+ })
+ return TagCloudForModelNode(bits[1], bits[3], **kwargs)
+
+def do_tags_for_object(parser, token):
+ """
+ Retrieves a list of ``Tag`` objects associated with an object and
+ stores them in a context variable.
+
+ Usage::
+
+ {% tags_for_object [object] as [varname] %}
+
+ Example::
+
+ {% tags_for_object foo_object as tag_list %}
+ """
+ bits = token.contents.split()
+ if len(bits) != 4:
+ raise TemplateSyntaxError(_('%s tag requires exactly three arguments') % bits[0])
+ if bits[2] != 'as':
+ raise TemplateSyntaxError(_("second argument to %s tag must be 'as'") % bits[0])
+ return TagsForObjectNode(bits[1], bits[3])
+
+def do_tagged_objects(parser, token):
+ """
+ Retrieves a list of instances of a given model which are tagged with
+ a given ``Tag`` and stores them in a context variable.
+
+ Usage::
+
+ {% tagged_objects [tag] in [model] as [varname] %}
+
+ The model is specified in ``[appname].[modelname]`` format.
+
+ The tag must be an instance of a ``Tag``, not the name of a tag.
+
+ Example::
+
+ {% tagged_objects comedy_tag in tv.Show as comedies %}
+
+ """
+ bits = token.contents.split()
+ if len(bits) != 6:
+ raise TemplateSyntaxError(_('%s tag requires exactly five arguments') % bits[0])
+ if bits[2] != 'in':
+ raise TemplateSyntaxError(_("second argument to %s tag must be 'in'") % bits[0])
+ if bits[4] != 'as':
+ raise TemplateSyntaxError(_("fourth argument to %s tag must be 'as'") % bits[0])
+ return TaggedObjectsNode(bits[1], bits[3], bits[5])
+
+register.tag('tags_for_model', do_tags_for_model)
+register.tag('tag_cloud_for_model', do_tag_cloud_for_model)
+register.tag('tags_for_object', do_tags_for_object)
+register.tag('tagged_objects', do_tagged_objects)
diff --git a/src/calibre/www/apps/tagging/utils.py b/src/calibre/www/apps/tagging/utils.py
new file mode 100644
index 0000000000..5750c7a0f7
--- /dev/null
+++ b/src/calibre/www/apps/tagging/utils.py
@@ -0,0 +1,263 @@
+"""
+Tagging utilities - from user tag input parsing to tag cloud
+calculation.
+"""
+import math
+import types
+
+from django.db.models.query import QuerySet
+from django.utils.encoding import force_unicode
+from django.utils.translation import ugettext as _
+
+# Python 2.3 compatibility
+try:
+ set
+except NameError:
+ from sets import Set as set
+
+def parse_tag_input(input):
+ """
+ Parses tag input, with multiple word input being activated and
+ delineated by commas and double quotes. Quotes take precedence, so
+ they may contain commas.
+
+ Returns a sorted list of unique tag names.
+ """
+ if not input:
+ return []
+
+ input = force_unicode(input)
+
+ # Special case - if there are no commas or double quotes in the
+ # input, we don't *do* a recall... I mean, we know we only need to
+ # split on spaces.
+ if u',' not in input and u'"' not in input:
+ words = list(set(split_strip(input, u' ')))
+ words.sort()
+ return words
+
+ words = []
+ buffer = []
+ # Defer splitting of non-quoted sections until we know if there are
+ # any unquoted commas.
+ to_be_split = []
+ saw_loose_comma = False
+ open_quote = False
+ i = iter(input)
+ try:
+ while 1:
+ c = i.next()
+ if c == u'"':
+ if buffer:
+ to_be_split.append(u''.join(buffer))
+ buffer = []
+ # Find the matching quote
+ open_quote = True
+ c = i.next()
+ while c != u'"':
+ buffer.append(c)
+ c = i.next()
+ if buffer:
+ word = u''.join(buffer).strip()
+ if word:
+ words.append(word)
+ buffer = []
+ open_quote = False
+ else:
+ if not saw_loose_comma and c == u',':
+ saw_loose_comma = True
+ buffer.append(c)
+ except StopIteration:
+ # If we were parsing an open quote which was never closed treat
+ # the buffer as unquoted.
+ if buffer:
+ if open_quote and u',' in buffer:
+ saw_loose_comma = True
+ to_be_split.append(u''.join(buffer))
+ if to_be_split:
+ if saw_loose_comma:
+ delimiter = u','
+ else:
+ delimiter = u' '
+ for chunk in to_be_split:
+ words.extend(split_strip(chunk, delimiter))
+ words = list(set(words))
+ words.sort()
+ return words
+
+def split_strip(input, delimiter=u','):
+ """
+ Splits ``input`` on ``delimiter``, stripping each resulting string
+ and returning a list of non-empty strings.
+ """
+ if not input:
+ return []
+
+ words = [w.strip() for w in input.split(delimiter)]
+ return [w for w in words if w]
+
+def edit_string_for_tags(tags):
+ """
+ Given list of ``Tag`` instances, creates a string representation of
+ the list suitable for editing by the user, such that submitting the
+ given string representation back without changing it will give the
+ same list of tags.
+
+ Tag names which contain commas will be double quoted.
+
+ If any tag name which isn't being quoted contains whitespace, the
+ resulting string of tag names will be comma-delimited, otherwise
+ it will be space-delimited.
+ """
+ names = []
+ use_commas = False
+ for tag in tags:
+ name = tag.name
+ if u',' in name:
+ names.append('"%s"' % name)
+ continue
+ elif u' ' in name:
+ if not use_commas:
+ use_commas = True
+ names.append(name)
+ if use_commas:
+ glue = u', '
+ else:
+ glue = u' '
+ return glue.join(names)
+
+def get_queryset_and_model(queryset_or_model):
+ """
+ Given a ``QuerySet`` or a ``Model``, returns a two-tuple of
+ (queryset, model).
+
+ If a ``Model`` is given, the ``QuerySet`` returned will be created
+ using its default manager.
+ """
+ try:
+ return queryset_or_model, queryset_or_model.model
+ except AttributeError:
+ return queryset_or_model._default_manager.all(), queryset_or_model
+
+def get_tag_list(tags):
+ """
+ Utility function for accepting tag input in a flexible manner.
+
+ If a ``Tag`` object is given, it will be returned in a list as
+ its single occupant.
+
+ If given, the tag names in the following will be used to create a
+ ``Tag`` ``QuerySet``:
+
+ * A string, which may contain multiple tag names.
+ * A list or tuple of strings corresponding to tag names.
+ * A list or tuple of integers corresponding to tag ids.
+
+ If given, the following will be returned as-is:
+
+ * A list or tuple of ``Tag`` objects.
+ * A ``Tag`` ``QuerySet``.
+
+ """
+ from calibre.www.apps.tagging.models import Tag
+ if isinstance(tags, Tag):
+ return [tags]
+ elif isinstance(tags, QuerySet) and tags.model is Tag:
+ return tags
+ elif isinstance(tags, types.StringTypes):
+ return Tag.objects.filter(name__in=parse_tag_input(tags))
+ elif isinstance(tags, (types.ListType, types.TupleType)):
+ if len(tags) == 0:
+ return tags
+ contents = set()
+ for item in tags:
+ if isinstance(item, types.StringTypes):
+ contents.add('string')
+ elif isinstance(item, Tag):
+ contents.add('tag')
+ elif isinstance(item, (types.IntType, types.LongType)):
+ contents.add('int')
+ if len(contents) == 1:
+ if 'string' in contents:
+ return Tag.objects.filter(name__in=[force_unicode(tag) \
+ for tag in tags])
+ elif 'tag' in contents:
+ return tags
+ elif 'int' in contents:
+ return Tag.objects.filter(id__in=tags)
+ else:
+ raise ValueError(_('If a list or tuple of tags is provided, they must all be tag names, Tag objects or Tag ids.'))
+ else:
+ raise ValueError(_('The tag input given was invalid.'))
+
+def get_tag(tag):
+ """
+ Utility function for accepting single tag input in a flexible
+ manner.
+
+ If a ``Tag`` object is given it will be returned as-is; if a
+ string or integer are given, they will be used to lookup the
+ appropriate ``Tag``.
+
+ If no matching tag can be found, ``None`` will be returned.
+ """
+ from calibre.www.apps.tagging.models import Tag
+ if isinstance(tag, Tag):
+ return tag
+
+ try:
+ if isinstance(tag, types.StringTypes):
+ return Tag.objects.get(name=tag)
+ elif isinstance(tag, (types.IntType, types.LongType)):
+ return Tag.objects.get(id=tag)
+ except Tag.DoesNotExist:
+ pass
+
+ return None
+
+# Font size distribution algorithms
+LOGARITHMIC, LINEAR = 1, 2
+
+def _calculate_thresholds(min_weight, max_weight, steps):
+ delta = (max_weight - min_weight) / float(steps)
+ return [min_weight + i * delta for i in range(1, steps + 1)]
+
+def _calculate_tag_weight(weight, max_weight, distribution):
+ """
+ Logarithmic tag weight calculation is based on code from the
+ `Tag Cloud`_ plugin for Mephisto, by Sven Fuchs.
+
+ .. _`Tag Cloud`: http://www.artweb-design.de/projects/mephisto-plugin-tag-cloud
+ """
+ if distribution == LINEAR or max_weight == 1:
+ return weight
+ elif distribution == LOGARITHMIC:
+ return math.log(weight) * max_weight / math.log(max_weight)
+ raise ValueError(_('Invalid distribution algorithm specified: %s.') % distribution)
+
+def calculate_cloud(tags, steps=4, distribution=LOGARITHMIC):
+ """
+ Add a ``font_size`` attribute to each tag according to the
+ frequency of its use, as indicated by its ``count``
+ attribute.
+
+ ``steps`` defines the range of font sizes - ``font_size`` will
+ be an integer between 1 and ``steps`` (inclusive).
+
+ ``distribution`` defines the type of font size distribution
+ algorithm which will be used - logarithmic or linear. It must be
+ one of ``tagging.utils.LOGARITHMIC`` or ``tagging.utils.LINEAR``.
+ """
+ if len(tags) > 0:
+ counts = [tag.count for tag in tags]
+ min_weight = float(min(counts))
+ max_weight = float(max(counts))
+ thresholds = _calculate_thresholds(min_weight, max_weight, steps)
+ for tag in tags:
+ font_set = False
+ tag_weight = _calculate_tag_weight(tag.count, max_weight, distribution)
+ for i in range(steps):
+ if not font_set and tag_weight <= thresholds[i]:
+ tag.font_size = i + 1
+ font_set = True
+ return tags
diff --git a/src/calibre/www/apps/tagging/views.py b/src/calibre/www/apps/tagging/views.py
new file mode 100644
index 0000000000..53360da383
--- /dev/null
+++ b/src/calibre/www/apps/tagging/views.py
@@ -0,0 +1,52 @@
+"""
+Tagging related views.
+"""
+from django.http import Http404
+from django.utils.translation import ugettext as _
+from django.views.generic.list_detail import object_list
+
+from calibre.www.apps.tagging.models import Tag, TaggedItem
+from calibre.www.apps.tagging.utils import get_tag, get_queryset_and_model
+
+def tagged_object_list(request, queryset_or_model=None, tag=None,
+ related_tags=False, related_tag_counts=True, **kwargs):
+ """
+ A thin wrapper around
+ ``django.views.generic.list_detail.object_list`` which creates a
+ ``QuerySet`` containing instances of the given queryset or model
+ tagged with the given tag.
+
+ In addition to the context variables set up by ``object_list``, a
+ ``tag`` context variable will contain the ``Tag`` instance for the
+ tag.
+
+ If ``related_tags`` is ``True``, a ``related_tags`` context variable
+ will contain tags related to the given tag for the given model.
+ Additionally, if ``related_tag_counts`` is ``True``, each related
+ tag will have a ``count`` attribute indicating the number of items
+ which have it in addition to the given tag.
+ """
+ if queryset_or_model is None:
+ try:
+ queryset_or_model = kwargs.pop('queryset_or_model')
+ except KeyError:
+ raise AttributeError(_('tagged_object_list must be called with a queryset or a model.'))
+
+ if tag is None:
+ try:
+ tag = kwargs.pop('tag')
+ except KeyError:
+ raise AttributeError(_('tagged_object_list must be called with a tag.'))
+
+ tag_instance = get_tag(tag)
+ if tag_instance is None:
+ raise Http404(_('No Tag found matching "%s".') % tag)
+ queryset = TaggedItem.objects.get_by_model(queryset_or_model, tag_instance)
+ if not kwargs.has_key('extra_context'):
+ kwargs['extra_context'] = {}
+ kwargs['extra_context']['tag'] = tag_instance
+ if related_tags:
+ kwargs['extra_context']['related_tags'] = \
+ Tag.objects.related_for_model(tag_instance, queryset_or_model,
+ counts=related_tag_counts)
+ return object_list(request, queryset, **kwargs)
diff --git a/src/calibre/www/kovid/__init__.py b/src/calibre/www/kovid/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/calibre/www/kovid/manage.py b/src/calibre/www/kovid/manage.py
new file mode 100644
index 0000000000..5e78ea979e
--- /dev/null
+++ b/src/calibre/www/kovid/manage.py
@@ -0,0 +1,11 @@
+#!/usr/bin/env python
+from django.core.management import execute_manager
+try:
+ import settings # Assumed to be in the same directory.
+except ImportError:
+ import sys
+ sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__)
+ sys.exit(1)
+
+if __name__ == "__main__":
+ execute_manager(settings)
diff --git a/src/calibre/www/kovid/settings.py b/src/calibre/www/kovid/settings.py
new file mode 100644
index 0000000000..8d89c5e373
--- /dev/null
+++ b/src/calibre/www/kovid/settings.py
@@ -0,0 +1,49 @@
+# Django settings for planet project.
+
+from calibre.www.settings import DEBUG, TEMPLATE_DEBUG, ADMINS, MANAGERS, \
+ TEMPLATE_LOADERS, TEMPLATE_DIRS, MIDDLEWARE_CLASSES, MEDIA_ROOT, \
+ MEDIA_URL, ADMIN_MEDIA_PREFIX, TEMPLATE_CONTEXT_PROCESSORS
+
+FORCE_LOWERCASE_TAGS = False
+MAX_TAG_LENGTH = 50
+
+if not DEBUG:
+ MEDIA_URL = 'http://kovid.calibre-ebook.com/site_media/'
+ ADMIN_MEDIA_PREFIX = 'http://kovid.calibre-ebook.com/admin_media/'
+ MEDIA_ROOT = '/usr/local/calibre/src/calibre/www/static/'
+
+
+if DEBUG:
+ DATABASE_ENGINE = 'sqlite3' # 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'.
+ DATABASE_NAME = '/tmp/kovid.db' # Or path to database file if using sqlite3.
+else:
+ DATABASE_ENGINE = 'mysql'
+ DATABASE_NAME = 'calibre_kovid'
+ DATABASE_USER = 'calibre_django'
+ DATABASE_PASSWORD = open('/var/www/calibre-ebook.com/dbpass').read().strip()
+
+SITE_ID = 1
+
+# Make this unique, and don't share it with anybody.
+if DEBUG:
+ SECRET_KEY = '06mv&t$cobjkijgg#0ndwm5#&90_(tm=oqi1bv-x^vii$*33n5'
+else:
+ SECRET_KEY = open('/var/www/kovid.calibre-ebook.com/django_secret_key').read().strip()
+
+
+ROOT_URLCONF = 'calibre.www.kovid.urls'
+
+INSTALLED_APPS = (
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.sites',
+ 'django.contrib.admin',
+ 'django.contrib.comments',
+ 'calibre.www.apps.inlines',
+ 'calibre.www.apps.tagging',
+ 'calibre.www.apps.blog',
+
+)
+
+
diff --git a/src/calibre/www/kovid/urls.py b/src/calibre/www/kovid/urls.py
new file mode 100644
index 0000000000..e5a33b2597
--- /dev/null
+++ b/src/calibre/www/kovid/urls.py
@@ -0,0 +1,24 @@
+from django.conf.urls.defaults import patterns, include, handler404, handler500
+from django.conf import settings
+
+# Uncomment the next two lines to enable the admin:
+#from django.contrib import admin
+#admin.autodiscover()
+
+urlpatterns = patterns('',
+
+ # (r'^admin/(.*)', admin.site.root),
+
+ (r'^comments/', include('django.contrib.comments.urls')),
+
+
+)
+
+if settings.DEBUG:
+ urlpatterns += patterns('',
+ (r'^site_media/(?P.*)$', 'django.views.static.serve',
+ {'document_root': settings.MEDIA_ROOT}),
+ )
+
+
+
diff --git a/src/calibre/www/static/styles/base.css b/src/calibre/www/static/styles/base.css
index c744301796..96dd38ffa4 100644
--- a/src/calibre/www/static/styles/base.css
+++ b/src/calibre/www/static/styles/base.css
@@ -1,6 +1,6 @@
body {
font-family: sansserif;
- background-color: #f6f6f6;
+ background-color: #f1fff1;
}
img {
@@ -12,7 +12,7 @@ img {
border-bottom: 1px solid black;
margin-bottom: 20px;
height: 100px;
- background-color: #d6d6d6;
+ background-color: #A2B964;
overflow: hidden;
}
Comments
+ {% for comment in comment_list %} + {% if comment.is_public %} ++ {{ forloop.counter }} + {% if comment.user_url %}{{ comment.user_name }}{% else %}{{ comment.user_name }}{% endif %} says... +
+ {{ comment.comment|urlizetrunc:"60"|markdown:"safe" }} +Posted at {{ comment.submit_date|date:"P" }} on {{ comment.submit_date|date:"F j, Y" }}
+