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.title }}

+

{{ 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.title }}

+

{{ 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.title }}

+

{{ 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 }}

+ +{% 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 %} +
+ +

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" }}

+
+ {% endif %} + {% endfor %} +
+ {% endif %} + {% if object.allow_comments %} + {% render_comment_form for object %} + {% else %} +
+

Comments are closed.

+

Comments have been close for this post.

+
+ {% 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.title }}

+

{{ post.publish|date:"Y F d" }}

+

{{ post.tease }}

+
+ {% endfor %} +
+ + {% if is_paginated %} +

+ {% if has_next %} + Older + {% endif %} + {% if has_next and has_previous %} | {% endif %} + {% if has_previous %} + Newer + {% endif %} +

+ {% 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.title }}

+

{{ post.publish|date:"Y F d" }}

+

{{ post.tease }}

+

{% if comment_count %}{{ comment_count }} comment{{ comment_count|pluralize }}{% endif %}

+
+ {% 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; }