Updated links to the "project website" and made the READMEs consistent with the docs.
[philo.git] / philo / contrib / penfield / models.py
1 from datetime import date, datetime
2
3 from django.conf import settings
4 from django.conf.urls.defaults import url, patterns, include
5 from django.contrib.sites.models import Site, RequestSite
6 from django.contrib.syndication.views import add_domain
7 from django.db import models
8 from django.http import Http404, HttpResponse
9 from django.template import RequestContext, Template as DjangoTemplate
10 from django.utils import feedgenerator, tzinfo
11 from django.utils.datastructures import SortedDict
12 from django.utils.encoding import smart_unicode, force_unicode
13 from django.utils.html import escape
14
15 from philo.contrib.penfield.exceptions import HttpNotAcceptable
16 from philo.contrib.penfield.middleware import http_not_acceptable
17 from philo.exceptions import ViewCanNotProvideSubpath
18 from philo.models import Tag, Entity, MultiView, Page, register_value_model, Template
19 from philo.models.fields import TemplateField
20 from philo.utils import paginate
21
22 try:
23         import mimeparse
24 except:
25         mimeparse = None
26
27
28 ATOM = feedgenerator.Atom1Feed.mime_type
29 RSS = feedgenerator.Rss201rev2Feed.mime_type
30 FEEDS = SortedDict([
31         (ATOM, feedgenerator.Atom1Feed),
32         (RSS, feedgenerator.Rss201rev2Feed),
33 ])
34 FEED_CHOICES = (
35         (ATOM, "Atom"),
36         (RSS, "RSS"),
37 )
38
39
40 class FeedView(MultiView):
41         """
42         :class:`FeedView` handles a number of pages and related feeds for a single object such as a blog or newsletter. In addition to all other methods and attributes, :class:`FeedView` supports the same generic API as `django.contrib.syndication.views.Feed <http://docs.djangoproject.com/en/dev/ref/contrib/syndication/#django.contrib.syndication.django.contrib.syndication.views.Feed>`_.
43         
44         """
45         #: The type of feed which should be served by the :class:`FeedView`.
46         feed_type = models.CharField(max_length=50, choices=FEED_CHOICES, default=ATOM)
47         #: The suffix which will be appended to a page URL for a feed of its items. Default: "feed"
48         feed_suffix = models.CharField(max_length=255, blank=False, default="feed")
49         #: A :class:`BooleanField` - whether or not feeds are enabled.
50         feeds_enabled = models.BooleanField(default=True)
51         #: A :class:`PositiveIntegerField` - the maximum number of items to return for this feed. All items will be returned if this field is blank. Default: 15.
52         feed_length = models.PositiveIntegerField(blank=True, null=True, default=15, help_text="The maximum number of items to return for this feed. All items will be returned if this field is blank.")
53         
54         #: A :class:`ForeignKey` to a :class:`.Template` which will be used to render the title of each item in the feed if provided.
55         item_title_template = models.ForeignKey(Template, blank=True, null=True, related_name="%(app_label)s_%(class)s_title_related")
56         #: A :class:`ForeignKey` to a :class:`.Template` which will be used to render the description of each item in the feed if provided.
57         item_description_template = models.ForeignKey(Template, blank=True, null=True, related_name="%(app_label)s_%(class)s_description_related")
58         
59         #: The name of the context variable to be populated with the items managed by the :class:`FeedView`.
60         item_context_var = 'items'
61         #: The attribute on a subclass of :class:`FeedView` which will contain the main object of a feed (such as a :class:`Blog`.)
62         object_attr = 'object'
63         
64         #: A description of the feeds served by the :class:`FeedView`. This is a required part of the :class:`django.contrib.syndication.view.Feed` API.
65         description = ""
66         
67         def feed_patterns(self, base, get_items_attr, page_attr, reverse_name):
68                 """
69                 Given the name to be used to reverse this view and the names of the attributes for the function that fetches the objects, returns patterns suitable for inclusion in urlpatterns.
70                 
71                 :param base: The base of the returned patterns - that is, the subpath pattern which will reference the page for the items. The :attr:`feed_suffix` will be appended to this subpath.
72                 :param get_items_attr: A callable or the name of a callable on the :class:`FeedView` which will return an (``items``, ``extra_context``) tuple. This will be passed directly to :meth:`feed_view` and :meth:`page_view`.
73                 :param page_attr: A :class:`.Page` instance or the name of an attribute on the :class:`FeedView` which contains a :class:`.Page` instance. This will be passed directly to :meth:`page_view` and will be rendered with the items from ``get_items_attr``.
74                 :param reverse_name: The string which is considered the "name" of the view function returned by :meth:`page_view` for the given parameters.
75                 :returns: Patterns suitable for use in urlpatterns.
76                 
77                 Example::
78                 
79                         @property
80                         def urlpatterns(self):
81                                 urlpatterns = self.feed_patterns(r'^', 'get_all_entries', 'index_page', 'index')
82                                 urlpatterns += self.feed_patterns(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})', 'get_entries_by_ymd', 'entry_archive_page', 'entries_by_day')
83                                 return urlpatterns
84                 
85                 """
86                 urlpatterns = patterns('')
87                 if self.feeds_enabled:
88                         feed_reverse_name = "%s_feed" % reverse_name
89                         feed_view = http_not_acceptable(self.feed_view(get_items_attr, feed_reverse_name))
90                         feed_pattern = r'%s%s%s$' % (base, (base and base[-1] != "^") and "/" or "", self.feed_suffix)
91                         urlpatterns += patterns('',
92                                 url(feed_pattern, feed_view, name=feed_reverse_name),
93                         )
94                 urlpatterns += patterns('',
95                         url(r"%s$" % base, self.page_view(get_items_attr, page_attr), name=reverse_name)
96                 )
97                 return urlpatterns
98         
99         def get_object(self, request, **kwargs):
100                 """By default, returns the object stored in the attribute named by :attr:`object_attr`. This can be overridden for subclasses that publish different data for different URL parameters. It is part of the :class:`django.contrib.syndication.views.Feed` API."""
101                 return getattr(self, self.object_attr)
102         
103         def feed_view(self, get_items_attr, reverse_name):
104                 """
105                 Returns a view function that renders a list of items as a feed.
106                 
107                 :param get_items_attr: A callable or the name of a callable on the :class:`FeedView` that will return a (items, extra_context) tuple when called with view arguments.
108                 :param reverse_name: The name which can be used reverse this feed using the :class:`FeedView` as the urlconf.
109                 
110                 :returns: A view function that renders a list of items as a feed.
111                 
112                 """
113                 get_items = callable(get_items_attr) and get_items_attr or getattr(self, get_items_attr)
114                 
115                 def inner(request, extra_context=None, *args, **kwargs):
116                         obj = self.get_object(request, *args, **kwargs)
117                         feed = self.get_feed(obj, request, reverse_name)
118                         items, xxx = get_items(request, extra_context=extra_context, *args, **kwargs)
119                         self.populate_feed(feed, items, request)
120                         
121                         response = HttpResponse(mimetype=feed.mime_type)
122                         feed.write(response, 'utf-8')
123                         return response
124                 
125                 return inner
126         
127         def page_view(self, get_items_attr, page_attr):
128                 """
129                 :param get_items_attr: A callable or the name of a callable on the :class:`FeedView` that will return a (items, extra_context) tuple when called with view arguments.
130                 :param page_attr: A :class:`.Page` instance or the name of an attribute on the :class:`FeedView` which contains a :class:`.Page` instance. This will be rendered with the items from ``get_items_attr``.
131                 
132                 :returns: A view function that renders a list of items as an :class:`HttpResponse`.
133                 
134                 """
135                 get_items = callable(get_items_attr) and get_items_attr or getattr(self, get_items_attr)
136                 page = isinstance(page_attr, Page) and page_attr or getattr(self, page_attr)
137                 
138                 def inner(request, extra_context=None, *args, **kwargs):
139                         items, extra_context = get_items(request, extra_context=extra_context, *args, **kwargs)
140                         items, item_context = self.process_page_items(request, items)
141                         
142                         context = self.get_context()
143                         context.update(extra_context or {})
144                         context.update(item_context or {})
145                         
146                         return page.render_to_response(request, extra_context=context)
147                 return inner
148         
149         def process_page_items(self, request, items):
150                 """
151                 Hook for handling any extra processing of ``items`` based on an :class:`HttpRequest`, such as pagination or searching. This method is expected to return a list of items and a dictionary to be added to the page context.
152                 
153                 """
154                 item_context = {
155                         self.item_context_var: items
156                 }
157                 return items, item_context
158         
159         def get_feed_type(self, request):
160                 """
161                 Intelligently chooses a feed type for a given request. Tries to return :attr:`feed_type`, but if the Accept header does not include that mimetype, tries to return the best match from the feed types that are offered by the :class:`FeedView`. If none of the offered feed types are accepted by the :class:`HttpRequest`, then this method will raise :exc:`philo.contrib.penfield.exceptions.HttpNotAcceptable`.
162                 
163                 """
164                 feed_type = self.feed_type
165                 if feed_type not in FEEDS:
166                         feed_type = FEEDS.keys()[0]
167                 accept = request.META.get('HTTP_ACCEPT')
168                 if accept and feed_type not in accept and "*/*" not in accept and "%s/*" % feed_type.split("/")[0] not in accept:
169                         # Wups! They aren't accepting the chosen format. Is there another format we can use?
170                         if mimeparse:
171                                 feed_type = mimeparse.best_match(FEEDS.keys(), accept)
172                         else:
173                                 for feed_type in FEEDS.keys():
174                                         if feed_type in accept or "%s/*" % feed_type.split("/")[0] in accept:
175                                                 break
176                                 else:
177                                         feed_type = None
178                         if not feed_type:
179                                 raise HttpNotAcceptable
180                 return FEEDS[feed_type]
181         
182         def get_feed(self, obj, request, reverse_name):
183                 """
184                 Returns an unpopulated :class:`django.utils.feedgenerator.DefaultFeed` object for this object.
185                 
186                 """
187                 try:
188                         current_site = Site.objects.get_current()
189                 except Site.DoesNotExist:
190                         current_site = RequestSite(request)
191                 
192                 feed_type = self.get_feed_type(request)
193                 node = request.node
194                 link = node.get_absolute_url(with_domain=True, request=request, secure=request.is_secure())
195                 
196                 feed = feed_type(
197                         title = self.__get_dynamic_attr('title', obj),
198                         subtitle = self.__get_dynamic_attr('subtitle', obj),
199                         link = link,
200                         description = self.__get_dynamic_attr('description', obj),
201                         language = settings.LANGUAGE_CODE.decode(),
202                         feed_url = add_domain(
203                                 current_site.domain,
204                                 self.__get_dynamic_attr('feed_url', obj) or node.construct_url(node._subpath, with_domain=True, request=request, secure=request.is_secure()),
205                                 request.is_secure()
206                         ),
207                         author_name = self.__get_dynamic_attr('author_name', obj),
208                         author_link = self.__get_dynamic_attr('author_link', obj),
209                         author_email = self.__get_dynamic_attr('author_email', obj),
210                         categories = self.__get_dynamic_attr('categories', obj),
211                         feed_copyright = self.__get_dynamic_attr('feed_copyright', obj),
212                         feed_guid = self.__get_dynamic_attr('feed_guid', obj),
213                         ttl = self.__get_dynamic_attr('ttl', obj),
214                         **self.feed_extra_kwargs(obj)
215                 )
216                 return feed
217         
218         def populate_feed(self, feed, items, request):
219                 """Populates a :class:`django.utils.feedgenerator.DefaultFeed` instance as is returned by :meth:`get_feed` with the passed-in ``items``."""
220                 if self.item_title_template:
221                         title_template = DjangoTemplate(self.item_title_template.code)
222                 else:
223                         title_template = None
224                 if self.item_description_template:
225                         description_template = DjangoTemplate(self.item_description_template.code)
226                 else:
227                         description_template = None
228                 
229                 node = request.node
230                 try:
231                         current_site = Site.objects.get_current()
232                 except Site.DoesNotExist:
233                         current_site = RequestSite(request)
234                 
235                 if self.feed_length is not None:
236                         items = items[:self.feed_length]
237                 
238                 for item in items:
239                         if title_template is not None:
240                                 title = title_template.render(RequestContext(request, {'obj': item}))
241                         else:
242                                 title = self.__get_dynamic_attr('item_title', item)
243                         if description_template is not None:
244                                 description = description_template.render(RequestContext(request, {'obj': item}))
245                         else:
246                                 description = self.__get_dynamic_attr('item_description', item)
247                         
248                         link = node.construct_url(self.reverse(obj=item), with_domain=True, request=request, secure=request.is_secure())
249                         
250                         enc = None
251                         enc_url = self.__get_dynamic_attr('item_enclosure_url', item)
252                         if enc_url:
253                                 enc = feedgenerator.Enclosure(
254                                         url = smart_unicode(add_domain(
255                                                         current_site.domain,
256                                                         enc_url,
257                                                         request.is_secure()
258                                         )),
259                                         length = smart_unicode(self.__get_dynamic_attr('item_enclosure_length', item)),
260                                         mime_type = smart_unicode(self.__get_dynamic_attr('item_enclosure_mime_type', item))
261                                 )
262                         author_name = self.__get_dynamic_attr('item_author_name', item)
263                         if author_name is not None:
264                                 author_email = self.__get_dynamic_attr('item_author_email', item)
265                                 author_link = self.__get_dynamic_attr('item_author_link', item)
266                         else:
267                                 author_email = author_link = None
268                         
269                         pubdate = self.__get_dynamic_attr('item_pubdate', item)
270                         if pubdate and not pubdate.tzinfo:
271                                 ltz = tzinfo.LocalTimezone(pubdate)
272                                 pubdate = pubdate.replace(tzinfo=ltz)
273                         
274                         feed.add_item(
275                                 title = title,
276                                 link = link,
277                                 description = description,
278                                 unique_id = self.__get_dynamic_attr('item_guid', item, link),
279                                 enclosure = enc,
280                                 pubdate = pubdate,
281                                 author_name = author_name,
282                                 author_email = author_email,
283                                 author_link = author_link,
284                                 categories = self.__get_dynamic_attr('item_categories', item),
285                                 item_copyright = self.__get_dynamic_attr('item_copyright', item),
286                                 **self.item_extra_kwargs(item)
287                         )
288         
289         def __get_dynamic_attr(self, attname, obj, default=None):
290                 try:
291                         attr = getattr(self, attname)
292                 except AttributeError:
293                         return default
294                 if callable(attr):
295                         # Check func_code.co_argcount rather than try/excepting the
296                         # function and catching the TypeError, because something inside
297                         # the function may raise the TypeError. This technique is more
298                         # accurate.
299                         if hasattr(attr, 'func_code'):
300                                 argcount = attr.func_code.co_argcount
301                         else:
302                                 argcount = attr.__call__.func_code.co_argcount
303                         if argcount == 2: # one argument is 'self'
304                                 return attr(obj)
305                         else:
306                                 return attr()
307                 return attr
308         
309         def feed_extra_kwargs(self, obj):
310                 """Returns an extra keyword arguments dictionary that is used when initializing the feed generator."""
311                 return {}
312         
313         def item_extra_kwargs(self, item):
314                 """Returns an extra keyword arguments dictionary that is used with the `add_item` call of the feed generator."""
315                 return {}
316         
317         def item_title(self, item):
318                 return escape(force_unicode(item))
319         
320         def item_description(self, item):
321                 return force_unicode(item)
322         
323         class Meta:
324                 abstract=True
325
326
327 class Blog(Entity):
328         """Represents a blog which can be posted to."""
329         #: The name of the :class:`Blog`, currently called 'title' for historical reasons.
330         title = models.CharField(max_length=255)
331         
332         #: A slug used to identify the :class:`Blog`.
333         slug = models.SlugField(max_length=255)
334         
335         def __unicode__(self):
336                 return self.title
337         
338         @property
339         def entry_tags(self):
340                 """Returns a :class:`QuerySet` of :class:`.Tag`\ s that are used on any entries in this blog."""
341                 return Tag.objects.filter(blogentries__blog=self).distinct()
342         
343         @property
344         def entry_dates(self):
345                 """Returns a dictionary of date :class:`QuerySet`\ s for years, months, and days for which there are entries."""
346                 dates = {'year': self.entries.dates('date', 'year', order='DESC'), 'month': self.entries.dates('date', 'month', order='DESC'), 'day': self.entries.dates('date', 'day', order='DESC')}
347                 return dates
348
349
350 register_value_model(Blog)
351
352
353 class BlogEntry(Entity):
354         """Represents an entry in a :class:`Blog`."""
355         #: The title of the :class:`BlogEntry`.
356         title = models.CharField(max_length=255)
357         
358         #: A slug which identifies the :class:`BlogEntry`.
359         slug = models.SlugField(max_length=255)
360         
361         #: The :class:`Blog` which this entry has been posted to. Can be left blank to represent a "draft" status.
362         blog = models.ForeignKey(Blog, related_name='entries', blank=True, null=True)
363         
364         #: A :class:`ForeignKey` to the author. The model is either :setting:`PHILO_PERSON_MODULE` or :class:`auth.User`.
365         author = models.ForeignKey(getattr(settings, 'PHILO_PERSON_MODULE', 'auth.User'), related_name='blogentries')
366         
367         #: The date and time which the :class:`BlogEntry` is considered posted at.
368         date = models.DateTimeField(default=None)
369         
370         #: The content of the :class:`BlogEntry`.
371         content = models.TextField()
372         
373         #: An optional brief excerpt from the :class:`BlogEntry`.
374         excerpt = models.TextField(blank=True, null=True)
375         
376         #: :class:`.Tag`\ s for this :class:`BlogEntry`.
377         tags = models.ManyToManyField(Tag, related_name='blogentries', blank=True, null=True)
378         
379         def save(self, *args, **kwargs):
380                 if self.date is None:
381                         self.date = datetime.now()
382                 super(BlogEntry, self).save(*args, **kwargs)
383         
384         def __unicode__(self):
385                 return self.title
386         
387         class Meta:
388                 ordering = ['-date']
389                 verbose_name_plural = "blog entries"
390                 get_latest_by = "date"
391
392
393 register_value_model(BlogEntry)
394
395
396 class BlogView(FeedView):
397         """
398         A subclass of :class:`FeedView` which handles patterns and feeds for a :class:`Blog` and its related :class:`entries <BlogEntry>`.
399         
400         """
401         ENTRY_PERMALINK_STYLE_CHOICES = (
402                 ('D', 'Year, month, and day'),
403                 ('M', 'Year and month'),
404                 ('Y', 'Year'),
405                 ('B', 'Custom base'),
406                 ('N', 'No base')
407         )
408         
409         #: The :class:`Blog` whose entries should be managed by this :class:`BlogView`
410         blog = models.ForeignKey(Blog, related_name='blogviews')
411         
412         #: The main page of the :class:`Blog` will be rendered with this :class:`.Page`.
413         index_page = models.ForeignKey(Page, related_name='blog_index_related')
414         #: The detail view of a :class:`BlogEntry` will be rendered with this :class:`Page`.
415         entry_page = models.ForeignKey(Page, related_name='blog_entry_related')
416         # TODO: entry_archive is misleading. Rename to ymd_page or timespan_page.
417         #: Views of :class:`BlogEntry` archives will be rendered with this :class:`Page` (optional).
418         entry_archive_page = models.ForeignKey(Page, related_name='blog_entry_archive_related', null=True, blank=True)
419         #: Views of :class:`BlogEntry` archives according to their :class:`.Tag`\ s will be rendered with this :class:`Page`.
420         tag_page = models.ForeignKey(Page, related_name='blog_tag_related')
421         #: The archive of all available tags will be rendered with this :class:`Page` (optional).
422         tag_archive_page = models.ForeignKey(Page, related_name='blog_tag_archive_related', null=True, blank=True)
423         #: This number will be passed directly into pagination for :class:`BlogEntry` list pages. Pagination will be disabled if this is left blank.
424         entries_per_page = models.IntegerField(blank=True, null=True)
425         
426         #: Depending on the needs of the site, different permalink styles may be appropriate. Example subpaths are provided for a :class:`BlogEntry` posted on May 2nd, 2011 with a slug of "hello". The choices are:
427         #: 
428         #:      * Year, month, and day - ``2011/05/02/hello``
429         #:      * Year and month - ``2011/05/hello``
430         #:      * Year - ``2011/hello``
431         #:      * Custom base - :attr:`entry_permalink_base`\ ``/hello``
432         #:      * No base - ``hello``
433         entry_permalink_style = models.CharField(max_length=1, choices=ENTRY_PERMALINK_STYLE_CHOICES)
434         #: If the :attr:`entry_permalink_style` is set to "Custom base" then the value of this field will be used as the base subpath for year/month/day entry archive pages and entry detail pages. Default: "entries"
435         entry_permalink_base = models.CharField(max_length=255, blank=False, default='entries')
436         #: This will be used as the base for the views of :attr:`tag_page` and :attr:`tag_archive_page`. Default: "tags"
437         tag_permalink_base = models.CharField(max_length=255, blank=False, default='tags')
438         
439         item_context_var = 'entries'
440         object_attr = 'blog'
441         
442         def __unicode__(self):
443                 return u'BlogView for %s' % self.blog.title
444         
445         def get_reverse_params(self, obj):
446                 if isinstance(obj, BlogEntry):
447                         if obj.blog == self.blog:
448                                 kwargs = {'slug': obj.slug}
449                                 if self.entry_permalink_style in 'DMY':
450                                         kwargs.update({'year': str(obj.date.year).zfill(4)})
451                                         if self.entry_permalink_style in 'DM':
452                                                 kwargs.update({'month': str(obj.date.month).zfill(2)})
453                                                 if self.entry_permalink_style == 'D':
454                                                         kwargs.update({'day': str(obj.date.day).zfill(2)})
455                                 return self.entry_view, [], kwargs
456                 elif isinstance(obj, Tag) or (isinstance(obj, models.query.QuerySet) and obj.model == Tag and obj):
457                         if isinstance(obj, Tag):
458                                 obj = [obj]
459                         slugs = [tag.slug for tag in obj if tag in self.get_tag_queryset()]
460                         if slugs:
461                                 return 'entries_by_tag', [], {'tag_slugs': "/".join(slugs)}
462                 elif isinstance(obj, (date, datetime)):
463                         kwargs = {
464                                 'year': str(obj.year).zfill(4),
465                                 'month': str(obj.month).zfill(2),
466                                 'day': str(obj.day).zfill(2)
467                         }
468                         return 'entries_by_day', [], kwargs
469                 raise ViewCanNotProvideSubpath
470         
471         @property
472         def urlpatterns(self):
473                 urlpatterns = self.feed_patterns(r'^', 'get_all_entries', 'index_page', 'index') +\
474                         self.feed_patterns(r'^%s/(?P<tag_slugs>[-\w]+[-+/\w]*)' % self.tag_permalink_base, 'get_entries_by_tag', 'tag_page', 'entries_by_tag')
475                 
476                 if self.tag_archive_page:
477                         urlpatterns += patterns('',
478                                 url((r'^%s$' % self.tag_permalink_base), self.tag_archive_view, name='tag_archive')
479                         )
480                 
481                 if self.entry_archive_page:
482                         if self.entry_permalink_style in 'DMY':
483                                 urlpatterns += self.feed_patterns(r'^(?P<year>\d{4})', 'get_entries_by_ymd', 'entry_archive_page', 'entries_by_year')
484                                 if self.entry_permalink_style in 'DM':
485                                         urlpatterns += self.feed_patterns(r'^(?P<year>\d{4})/(?P<month>\d{2})', 'get_entries_by_ymd', 'entry_archive_page', 'entries_by_month')
486                                         if self.entry_permalink_style == 'D':
487                                                 urlpatterns += self.feed_patterns(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})', 'get_entries_by_ymd', 'entry_archive_page', 'entries_by_day')
488                 
489                 if self.entry_permalink_style == 'D':
490                         urlpatterns += patterns('',
491                                 url(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<slug>[-\w]+)$', self.entry_view)
492                         )
493                 elif self.entry_permalink_style == 'M':
494                         urlpatterns += patterns('',
495                                 url(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<slug>[-\w]+)$', self.entry_view)
496                         )
497                 elif self.entry_permalink_style == 'Y':
498                         urlpatterns += patterns('',
499                                 url(r'^(?P<year>\d{4})/(?P<slug>[-\w]+)$', self.entry_view)
500                         )
501                 elif self.entry_permalink_style == 'B':
502                         urlpatterns += patterns('',
503                                 url((r'^%s/(?P<slug>[-\w]+)$' % self.entry_permalink_base), self.entry_view)
504                         )
505                 else:
506                         urlpatterns += patterns('',
507                                 url(r'^(?P<slug>[-\w]+)$', self.entry_view)
508                         )
509                 return urlpatterns
510         
511         def get_context(self):
512                 return {'blog': self.blog}
513         
514         def get_entry_queryset(self):
515                 """Returns the default :class:`QuerySet` of :class:`BlogEntry` instances for the :class:`BlogView` - all entries that are considered posted in the past. This allows for scheduled posting of entries."""
516                 return self.blog.entries.filter(date__lte=datetime.now())
517         
518         def get_tag_queryset(self):
519                 """Returns the default :class:`QuerySet` of :class:`.Tag`\ s for the :class:`BlogView`'s :meth:`get_entries_by_tag` and :meth:`tag_archive_view`."""
520                 return self.blog.entry_tags
521         
522         def get_all_entries(self, request, extra_context=None):
523                 """Used to generate :meth:`~FeedView.feed_patterns` for all entries."""
524                 return self.get_entry_queryset(), extra_context
525         
526         def get_entries_by_ymd(self, request, year=None, month=None, day=None, extra_context=None):
527                 """Used to generate :meth:`~FeedView.feed_patterns` for entries with a specific year, month, and day."""
528                 if not self.entry_archive_page:
529                         raise Http404
530                 entries = self.get_entry_queryset()
531                 if year:
532                         entries = entries.filter(date__year=year)
533                 if month:
534                         entries = entries.filter(date__month=month)
535                 if day:
536                         entries = entries.filter(date__day=day)
537                 
538                 context = extra_context or {}
539                 context.update({'year': year, 'month': month, 'day': day})
540                 return entries, context
541         
542         def get_entries_by_tag(self, request, tag_slugs, extra_context=None):
543                 """Used to generate :meth:`~FeedView.feed_patterns` for entries with all of the given tags."""
544                 tag_slugs = tag_slugs.replace('+', '/').split('/')
545                 tags = self.get_tag_queryset().filter(slug__in=tag_slugs)
546                 
547                 if not tags:
548                         raise Http404
549                 
550                 # Raise a 404 on an incorrect slug.
551                 found_slugs = [tag.slug for tag in tags]
552                 for slug in tag_slugs:
553                         if slug and slug not in found_slugs:
554                                 raise Http404
555
556                 entries = self.get_entry_queryset()
557                 for tag in tags:
558                         entries = entries.filter(tags=tag)
559                 
560                 context = extra_context or {}
561                 context.update({'tags': tags})
562                 
563                 return entries, context
564         
565         def entry_view(self, request, slug, year=None, month=None, day=None, extra_context=None):
566                 """Renders :attr:`entry_page` with the entry specified by the given parameters."""
567                 entries = self.get_entry_queryset()
568                 if year:
569                         entries = entries.filter(date__year=year)
570                 if month:
571                         entries = entries.filter(date__month=month)
572                 if day:
573                         entries = entries.filter(date__day=day)
574                 try:
575                         entry = entries.get(slug=slug)
576                 except:
577                         raise Http404
578                 context = self.get_context()
579                 context.update(extra_context or {})
580                 context.update({'entry': entry})
581                 return self.entry_page.render_to_response(request, extra_context=context)
582         
583         def tag_archive_view(self, request, extra_context=None):
584                 """Renders :attr:`tag_archive_page` with the result of :meth:`get_tag_queryset` added to the context."""
585                 if not self.tag_archive_page:
586                         raise Http404
587                 context = self.get_context()
588                 context.update(extra_context or {})
589                 context.update({
590                         'tags': self.get_tag_queryset()
591                 })
592                 return self.tag_archive_page.render_to_response(request, extra_context=context)
593         
594         def feed_view(self, get_items_attr, reverse_name):
595                 """Overrides :meth:`FeedView.feed_view` to add :class:`.Tag`\ s to the feed as categories."""
596                 get_items = callable(get_items_attr) and get_items_attr or getattr(self, get_items_attr)
597                 
598                 def inner(request, extra_context=None, *args, **kwargs):
599                         obj = self.get_object(request, *args, **kwargs)
600                         feed = self.get_feed(obj, request, reverse_name)
601                         items, extra_context = get_items(request, extra_context=extra_context, *args, **kwargs)
602                         self.populate_feed(feed, items, request)
603                         
604                         if 'tags' in extra_context:
605                                 tags = extra_context['tags']
606                                 feed.feed['link'] = request.node.construct_url(self.reverse(obj=tags), with_domain=True, request=request, secure=request.is_secure())
607                         else:
608                                 tags = obj.entry_tags
609                         
610                         feed.feed['categories'] = [tag.name for tag in tags]
611                         
612                         response = HttpResponse(mimetype=feed.mime_type)
613                         feed.write(response, 'utf-8')
614                         return response
615                 
616                 return inner
617         
618         def process_page_items(self, request, items):
619                 """Overrides :meth:`FeedView.process_page_items` to add pagination."""
620                 if self.entries_per_page:
621                         page_num = request.GET.get('page', 1)
622                         paginator, paginated_page, items = paginate(items, self.entries_per_page, page_num)
623                         item_context = {
624                                 'paginator': paginator,
625                                 'paginated_page': paginated_page,
626                                 self.item_context_var: items
627                         }
628                 else:
629                         item_context = {
630                                 self.item_context_var: items
631                         }
632                 return items, item_context
633         
634         def title(self, obj):
635                 return obj.title
636         
637         def item_title(self, item):
638                 return item.title
639         
640         def item_description(self, item):
641                 return item.content
642         
643         def item_author_name(self, item):
644                 return item.author.get_full_name()
645         
646         def item_pubdate(self, item):
647                 return item.date
648         
649         def item_categories(self, item):
650                 return [tag.name for tag in item.tags.all()]
651
652
653 class Newsletter(Entity):
654         """Represents a newsletter which will contain :class:`articles <NewsletterArticle>` organized into :class:`issues <NewsletterIssue>`."""
655         #: The name of the :class:`Newsletter`, currently callse 'title' for historical reasons.
656         title = models.CharField(max_length=255)
657         #: A slug used to identify the :class:`Newsletter`.
658         slug = models.SlugField(max_length=255)
659         
660         def __unicode__(self):
661                 return self.title
662
663
664 register_value_model(Newsletter)
665
666
667 class NewsletterArticle(Entity):
668         """Represents an article in a :class:`Newsletter`"""
669         #: The title of the :class:`NewsletterArticle`.
670         title = models.CharField(max_length=255)
671         #: A slug which identifies the :class:`NewsletterArticle`.
672         slug = models.SlugField(max_length=255)
673         #: A :class:`ForeignKey` to :class:`Newsletter` representing the newsletter which this article was written for.
674         newsletter = models.ForeignKey(Newsletter, related_name='articles')
675         #: A :class:`ManyToManyField` to the author(s) of the :class:`NewsletterArticle`. The model is either :setting:`PHILO_PERSON_MODULE` or :class:`auth.User`.
676         authors = models.ManyToManyField(getattr(settings, 'PHILO_PERSON_MODULE', 'auth.User'), related_name='newsletterarticles')
677         #: The date and time which the :class:`NewsletterArticle` is considered published at.
678         date = models.DateTimeField(default=None)
679         #: A :class:`.TemplateField` containing an optional short summary of the article, meant to grab a reader's attention and draw them in.
680         lede = TemplateField(null=True, blank=True, verbose_name='Summary')
681         #: A :class:`.TemplateField` containing the full text of the article.
682         full_text = TemplateField(db_index=True)
683         #: A :class:`ManyToManyField` to :class:`.Tag`\ s for the :class:`NewsletterArticle`.
684         tags = models.ManyToManyField(Tag, related_name='newsletterarticles', blank=True, null=True)
685         
686         def save(self, *args, **kwargs):
687                 if self.date is None:
688                         self.date = datetime.now()
689                 super(NewsletterArticle, self).save(*args, **kwargs)
690         
691         def __unicode__(self):
692                 return self.title
693         
694         class Meta:
695                 get_latest_by = 'date'
696                 ordering = ['-date']
697                 unique_together = (('newsletter', 'slug'),)
698
699
700 register_value_model(NewsletterArticle)
701
702
703 class NewsletterIssue(Entity):
704         """Represents an issue of the newsletter."""
705         #: The title of the :class:`NewsletterIssue`.
706         title = models.CharField(max_length=255)
707         #: A slug which identifies the :class:`NewsletterIssue`.
708         slug = models.SlugField(max_length=255)
709         #: A :class:`ForeignKey` to the :class:`Newsletter` which this issue belongs to.
710         newsletter = models.ForeignKey(Newsletter, related_name='issues')
711         #: The numbering of the issue - for example, 04.02 for volume 4, issue 2. This is an instance of :class:`CharField` to allow any arbitrary numbering system.
712         numbering = models.CharField(max_length=50, help_text='For example, 04.02 for volume 4, issue 2.')
713         #: A :class:`ManyToManyField` to articles belonging to this issue.
714         articles = models.ManyToManyField(NewsletterArticle, related_name='issues')
715         
716         def __unicode__(self):
717                 return self.title
718         
719         class Meta:
720                 ordering = ['-numbering']
721                 unique_together = (('newsletter', 'numbering'),)
722
723
724 register_value_model(NewsletterIssue)
725
726
727 class NewsletterView(FeedView):
728         """A subclass of :class:`FeedView` which handles patterns and feeds for a :class:`Newsletter` and its related :class:`articles <NewsletterArticle>`."""
729         ARTICLE_PERMALINK_STYLE_CHOICES = (
730                 ('D', 'Year, month, and day'),
731                 ('M', 'Year and month'),
732                 ('Y', 'Year'),
733                 ('S', 'Slug only')
734         )
735         
736         #: A :class:`ForeignKey` to the :class:`Newsletter` managed by this :class:`NewsletterView`.
737         newsletter = models.ForeignKey(Newsletter, related_name='newsletterviews')
738         
739         #: A :class:`ForeignKey` to the :class:`Page` used to render the main page of this :class:`NewsletterView`.
740         index_page = models.ForeignKey(Page, related_name='newsletter_index_related')
741         #: A :class:`ForeignKey` to the :class:`Page` used to render the detail view of a :class:`NewsletterArticle` for this :class:`NewsletterView`.
742         article_page = models.ForeignKey(Page, related_name='newsletter_article_related')
743         #: A :class:`ForeignKey` to the :class:`Page` used to render the :class:`NewsletterArticle` archive pages for this :class:`NewsletterView`.
744         article_archive_page = models.ForeignKey(Page, related_name='newsletter_article_archive_related', null=True, blank=True)
745         #: A :class:`ForeignKey` to the :class:`Page` used to render the detail view of a :class:`NewsletterIssue` for this :class:`NewsletterView`.
746         issue_page = models.ForeignKey(Page, related_name='newsletter_issue_related')
747         #: A :class:`ForeignKey` to the :class:`Page` used to render the :class:`NewsletterIssue` archive pages for this :class:`NewsletterView`.
748         issue_archive_page = models.ForeignKey(Page, related_name='newsletter_issue_archive_related', null=True, blank=True)
749         
750         #: Depending on the needs of the site, different permalink styles may be appropriate. Example subpaths are provided for a :class:`NewsletterArticle` posted on May 2nd, 2011 with a slug of "hello". The choices are:
751         #: 
752         #:      * Year, month, and day - :attr:`article_permalink_base`\ ``/2011/05/02/hello``
753         #:      * Year and month - :attr:`article_permalink_base`\ ``/2011/05/hello``
754         #:      * Year - :attr:`article_permalink_base`\ ``/2011/hello``
755         #:      * Slug only - :attr:`article_permalink_base`\ ``/hello``
756         article_permalink_style = models.CharField(max_length=1, choices=ARTICLE_PERMALINK_STYLE_CHOICES)
757         #: This will be used as the base subpath for year/month/day article archive pages and article detail pages. Default: "articles"
758         article_permalink_base = models.CharField(max_length=255, blank=False, default='articles')
759         #: This will be used as the base subpath for issue detail pages and the issue archive page.
760         issue_permalink_base = models.CharField(max_length=255, blank=False, default='issues')
761         
762         item_context_var = 'articles'
763         object_attr = 'newsletter'
764         
765         def __unicode__(self):
766                 return "NewsletterView for %s" % self.newsletter.__unicode__()
767         
768         def get_reverse_params(self, obj):
769                 if isinstance(obj, NewsletterArticle):
770                         if obj.newsletter == self.newsletter:
771                                 kwargs = {'slug': obj.slug}
772                                 if self.article_permalink_style in 'DMY':
773                                         kwargs.update({'year': str(obj.date.year).zfill(4)})
774                                         if self.article_permalink_style in 'DM':
775                                                 kwargs.update({'month': str(obj.date.month).zfill(2)})
776                                                 if self.article_permalink_style == 'D':
777                                                         kwargs.update({'day': str(obj.date.day).zfill(2)})
778                                 return self.article_view, [], kwargs
779                 elif isinstance(obj, NewsletterIssue):
780                         if obj.newsletter == self.newsletter:
781                                 return 'issue', [], {'numbering': obj.numbering}
782                 elif isinstance(obj, (date, datetime)):
783                         kwargs = {
784                                 'year': str(obj.year).zfill(4),
785                                 'month': str(obj.month).zfill(2),
786                                 'day': str(obj.day).zfill(2)
787                         }
788                         return 'articles_by_day', [], kwargs
789                 raise ViewCanNotProvideSubpath
790         
791         @property
792         def urlpatterns(self):
793                 urlpatterns = self.feed_patterns(r'^', 'get_all_articles', 'index_page', 'index') + patterns('',
794                         url(r'^%s/(?P<numbering>.+)$' % self.issue_permalink_base, self.page_view('get_articles_by_issue', 'issue_page'), name='issue')
795                 )
796                 if self.issue_archive_page:
797                         urlpatterns += patterns('',
798                                 url(r'^%s$' % self.issue_permalink_base, self.issue_archive_view, 'issue_archive')
799                         )
800                 if self.article_archive_page:
801                         urlpatterns += patterns('',
802                                 url(r'^%s' % self.article_permalink_base, include(self.feed_patterns('get_all_articles', 'article_archive_page', 'articles')))
803                         )
804                         if self.article_permalink_style in 'DMY':
805                                 urlpatterns += self.feed_patterns(r'^%s/(?P<year>\d{4})' % self.article_permalink_base, 'get_articles_by_ymd', 'article_archive_page', 'articles_by_year')
806                                 if self.article_permalink_style in 'DM':
807                                         urlpatterns += self.feed_patterns(r'^%s/(?P<year>\d{4})/(?P<month>\d{2})' % self.article_permalink_base, 'get_articles_by_ymd', 'article_archive_page', 'articles_by_month')
808                                         if self.article_permalink_style == 'D':
809                                                 urlpatterns += self.feed_patterns(r'^%s/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})' % self.article_permalink_base, 'get_articles_by_ymd', 'article_archive_page', 'articles_by_day')
810                 
811                 if self.article_permalink_style == 'Y':
812                         urlpatterns += patterns('',
813                                 url(r'^%s/(?P<year>\d{4})/(?P<slug>[\w-]+)$' % self.article_permalink_base, self.article_view)
814                         )
815                 elif self.article_permalink_style == 'M':
816                         urlpatterns += patterns('',
817                                 url(r'^%s/(?P<year>\d{4})/(?P<month>\d{2})/(?P<slug>[\w-]+)$' % self.article_permalink_base, self.article_view)
818                         )
819                 elif self.article_permalink_style == 'D':
820                         urlpatterns += patterns('',
821                                 url(r'^%s/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<slug>[\w-]+)$' % self.article_permalink_base, self.article_view)
822                         )
823                 else:   
824                         urlpatterns += patterns('',
825                                 url(r'^%s/(?P<slug>[-\w]+)$' % self.article_permalink_base, self.article_view)
826                         )
827                 
828                 return urlpatterns
829         
830         def get_context(self):
831                 return {'newsletter': self.newsletter}
832         
833         def get_article_queryset(self):
834                 """Returns the default :class:`QuerySet` of :class:`NewsletterArticle` instances for the :class:`NewsletterView` - all articles that are considered posted in the past. This allows for scheduled posting of articles."""
835                 return self.newsletter.articles.filter(date__lte=datetime.now())
836         
837         def get_issue_queryset(self):
838                 """Returns the default :class:`QuerySet` of :class:`NewsletterIssue` instances for the :class:`NewsletterView`."""
839                 return self.newsletter.issues.all()
840         
841         def get_all_articles(self, request, extra_context=None):
842                 """Used to generate :meth:`FeedView.feed_patterns` for all entries."""
843                 return self.get_article_queryset(), extra_context
844         
845         def get_articles_by_ymd(self, request, year, month=None, day=None, extra_context=None):
846                 """Used to generate :meth:`FeedView.feed_patterns` for a specific year, month, and day."""
847                 articles = self.get_article_queryset().filter(date__year=year)
848                 if month:
849                         articles = articles.filter(date__month=month)
850                 if day:
851                         articles = articles.filter(date__day=day)
852                 return articles, extra_context
853         
854         def get_articles_by_issue(self, request, numbering, extra_context=None):
855                 """Used to generate :meth:`FeedView.feed_patterns` for articles from a certain issue."""
856                 try:
857                         issue = self.get_issue_queryset().get(numbering=numbering)
858                 except NewsletterIssue.DoesNotExist:
859                         raise Http404
860                 context = extra_context or {}
861                 context.update({'issue': issue})
862                 return self.get_article_queryset().filter(issues=issue), context
863         
864         def article_view(self, request, slug, year=None, month=None, day=None, extra_context=None):
865                 """Renders :attr:`article_page` with the article specified by the given parameters."""
866                 articles = self.get_article_queryset()
867                 if year:
868                         articles = articles.filter(date__year=year)
869                 if month:
870                         articles = articles.filter(date__month=month)
871                 if day:
872                         articles = articles.filter(date__day=day)
873                 try:
874                         article = articles.get(slug=slug)
875                 except NewsletterArticle.DoesNotExist:
876                         raise Http404
877                 context = self.get_context()
878                 context.update(extra_context or {})
879                 context.update({'article': article})
880                 return self.article_page.render_to_response(request, extra_context=context)
881         
882         def issue_archive_view(self, request, extra_context):
883                 """Renders :attr:`issue_archive_page` with the result of :meth:`get_issue_queryset` added to the context."""
884                 if not self.issue_archive_page:
885                         raise Http404
886                 context = self.get_context()
887                 context.update(extra_context or {})
888                 context.update({
889                         'issues': self.get_issue_queryset()
890                 })
891                 return self.issue_archive_page.render_to_response(request, extra_context=context)
892         
893         def title(self, obj):
894                 return obj.title
895         
896         def item_title(self, item):
897                 return item.title
898         
899         def item_description(self, item):
900                 return item.full_text
901         
902         def item_author_name(self, item):
903                 authors = list(item.authors.all())
904                 if len(authors) > 1:
905                         return "%s and %s" % (", ".join([author.get_full_name() for author in authors[:-1]]), authors[-1].get_full_name())
906                 elif authors:
907                         return authors[0].get_full_name()
908                 else:
909                         return ''
910         
911         def item_pubdate(self, item):
912                 return item.date
913         
914         def item_categories(self, item):
915                 return [tag.name for tag in item.tags.all()]