1 from datetime import date, datetime
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
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
28 ATOM = feedgenerator.Atom1Feed.mime_type
29 RSS = feedgenerator.Rss201rev2Feed.mime_type
31 (ATOM, feedgenerator.Atom1Feed),
32 (RSS, feedgenerator.Rss201rev2Feed),
40 class FeedView(MultiView):
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>`_.
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.")
54 #: A :class:`ForeignKey` to a :class:`.Template` which can be used to render the title of each item in the feed.
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 can be used to render the description of each item in the feed.
57 item_description_template = models.ForeignKey(Template, blank=True, null=True, related_name="%(app_label)s_%(class)s_description_related")
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'
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.
67 def feed_patterns(self, base, get_items_attr, page_attr, reverse_name):
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.
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.
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')
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),
94 urlpatterns += patterns('',
95 url(r"%s$" % base, self.page_view(get_items_attr, page_attr), name=reverse_name)
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)
103 def feed_view(self, get_items_attr, reverse_name):
105 Returns a view function that renders a list of items as a feed.
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.
110 :returns: A view function that renders a list of items as a feed.
113 get_items = callable(get_items_attr) and get_items_attr or getattr(self, get_items_attr)
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)
121 response = HttpResponse(mimetype=feed.mime_type)
122 feed.write(response, 'utf-8')
127 def page_view(self, get_items_attr, page_attr):
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``.
132 :returns: A view function that renders a list of items as an :class:`HttpResponse`.
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)
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)
142 context = self.get_context()
143 context.update(extra_context or {})
144 context.update(item_context or {})
146 return page.render_to_response(request, extra_context=context)
149 def process_page_items(self, request, items):
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.
155 self.item_context_var: items
157 return items, item_context
159 def get_feed_type(self, request):
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`.
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?
171 feed_type = mimeparse.best_match(FEEDS.keys(), accept)
173 for feed_type in FEEDS.keys():
174 if feed_type in accept or "%s/*" % feed_type.split("/")[0] in accept:
179 raise HttpNotAcceptable
180 return FEEDS[feed_type]
182 def get_feed(self, obj, request, reverse_name):
184 Returns an unpopulated :class:`django.utils.feedgenerator.DefaultFeed` object for this object.
188 current_site = Site.objects.get_current()
189 except Site.DoesNotExist:
190 current_site = RequestSite(request)
192 feed_type = self.get_feed_type(request)
194 link = node.get_absolute_url(with_domain=True, request=request, secure=request.is_secure())
197 title = self.__get_dynamic_attr('title', obj),
198 subtitle = self.__get_dynamic_attr('subtitle', obj),
200 description = self.__get_dynamic_attr('description', obj),
201 language = settings.LANGUAGE_CODE.decode(),
202 feed_url = add_domain(
204 self.__get_dynamic_attr('feed_url', obj) or node.construct_url(node.subpath, with_domain=True, request=request, secure=request.is_secure()),
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)
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)
223 title_template = None
224 if self.item_description_template:
225 description_template = DjangoTemplate(self.item_description_template.code)
227 description_template = None
231 current_site = Site.objects.get_current()
232 except Site.DoesNotExist:
233 current_site = RequestSite(request)
235 if self.feed_length is not None:
236 items = items[:self.feed_length]
239 if title_template is not None:
240 title = title_template.render(RequestContext(request, {'obj': item}))
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}))
246 description = self.__get_dynamic_attr('item_description', item)
248 link = node.construct_url(self.reverse(obj=item), with_domain=True, request=request, secure=request.is_secure())
251 enc_url = self.__get_dynamic_attr('item_enclosure_url', item)
253 enc = feedgenerator.Enclosure(
254 url = smart_unicode(add_domain(
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))
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)
267 author_email = author_link = None
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)
277 description = description,
278 unique_id = self.__get_dynamic_attr('item_guid', item, link),
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)
289 def __get_dynamic_attr(self, attname, obj, default=None):
291 attr = getattr(self, attname)
292 except AttributeError:
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
299 if hasattr(attr, 'func_code'):
300 argcount = attr.func_code.co_argcount
302 argcount = attr.__call__.func_code.co_argcount
303 if argcount == 2: # one argument is 'self'
309 def feed_extra_kwargs(self, obj):
310 """Returns an extra keyword arguments dictionary that is used when initializing the feed generator."""
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."""
317 def item_title(self, item):
318 return escape(force_unicode(item))
320 def item_description(self, item):
321 return force_unicode(item)
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)
332 #: A slug used to identify the :class:`Blog`.
333 slug = models.SlugField(max_length=255)
335 def __unicode__(self):
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()
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')}
350 register_value_model(Blog)
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)
358 #: A slug which identifies the :class:`BlogEntry`.
359 slug = models.SlugField(max_length=255)
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)
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')
367 #: The date and time which the :class:`BlogEntry` is considered posted at.
368 date = models.DateTimeField(default=None)
370 #: The content of the :class:`BlogEntry`.
371 content = models.TextField()
373 #: An optional brief excerpt from the :class:`BlogEntry`.
374 excerpt = models.TextField(blank=True, null=True)
376 #: :class:`.Tag`\ s for this :class:`BlogEntry`.
377 tags = models.ManyToManyField(Tag, related_name='blogentries', blank=True, null=True)
379 def save(self, *args, **kwargs):
380 if self.date is None:
381 self.date = datetime.now()
382 super(BlogEntry, self).save(*args, **kwargs)
384 def __unicode__(self):
389 verbose_name_plural = "blog entries"
390 get_latest_by = "date"
393 register_value_model(BlogEntry)
396 class BlogView(FeedView):
398 A subclass of :class:`FeedView` which handles patterns and feeds for a :class:`Blog` and its related :class:`entries <BlogEntry>`.
401 ENTRY_PERMALINK_STYLE_CHOICES = (
402 ('D', 'Year, month, and day'),
403 ('M', 'Year and month'),
405 ('B', 'Custom base'),
409 #: The :class:`Blog` whose entries should be managed by this :class:`BlogView`
410 blog = models.ForeignKey(Blog, related_name='blogviews')
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)
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:
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')
439 item_context_var = 'entries'
442 def __unicode__(self):
443 return u'BlogView for %s' % self.blog.title
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):
459 slugs = [tag.slug for tag in obj if tag in self.get_tag_queryset()]
461 return 'entries_by_tag', [], {'tag_slugs': "/".join(slugs)}
462 elif isinstance(obj, (date, datetime)):
464 'year': str(obj.year).zfill(4),
465 'month': str(obj.month).zfill(2),
466 'day': str(obj.day).zfill(2)
468 return 'entries_by_day', [], kwargs
469 raise ViewCanNotProvideSubpath
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')
476 if self.tag_archive_page:
477 urlpatterns += patterns('',
478 url((r'^%s$' % self.tag_permalink_base), self.tag_archive_view, name='tag_archive')
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')
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)
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)
497 elif self.entry_permalink_style == 'Y':
498 urlpatterns += patterns('',
499 url(r'^(?P<year>\d{4})/(?P<slug>[-\w]+)$', self.entry_view)
501 elif self.entry_permalink_style == 'B':
502 urlpatterns += patterns('',
503 url((r'^%s/(?P<slug>[-\w]+)$' % self.entry_permalink_base), self.entry_view)
506 urlpatterns += patterns('',
507 url(r'^(?P<slug>[-\w]+)$', self.entry_view)
511 def get_context(self):
512 return {'blog': self.blog}
514 def get_entry_queryset(self):
515 """Returns the default :class:`QuerySet` of :class:`BlogEntry` instances for the :class:`BlogView`."""
516 return self.blog.entries.all()
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
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
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:
530 entries = self.get_entry_queryset()
532 entries = entries.filter(date__year=year)
534 entries = entries.filter(date__month=month)
536 entries = entries.filter(date__day=day)
538 context = extra_context or {}
539 context.update({'year': year, 'month': month, 'day': day})
540 return entries, context
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)
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:
556 entries = self.get_entry_queryset()
558 entries = entries.filter(tags=tag)
560 context = extra_context or {}
561 context.update({'tags': tags})
563 return entries, context
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()
569 entries = entries.filter(date__year=year)
571 entries = entries.filter(date__month=month)
573 entries = entries.filter(date__day=day)
575 entry = entries.get(slug=slug)
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)
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:
587 context = self.get_context()
588 context.update(extra_context or {})
590 'tags': self.get_tag_queryset()
592 return self.tag_archive_page.render_to_response(request, extra_context=context)
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)
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)
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())
608 tags = obj.entry_tags
610 feed.feed['categories'] = [tag.name for tag in tags]
612 response = HttpResponse(mimetype=feed.mime_type)
613 feed.write(response, 'utf-8')
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)
624 'paginator': paginator,
625 'paginated_page': paginated_page,
626 self.item_context_var: items
630 self.item_context_var: items
632 return items, item_context
634 def title(self, obj):
637 def item_title(self, item):
640 def item_description(self, item):
643 def item_author_name(self, item):
644 return item.author.get_full_name()
646 def item_pubdate(self, item):
649 def item_categories(self, item):
650 return [tag.name for tag in item.tags.all()]
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)
660 def __unicode__(self):
664 register_value_model(Newsletter)
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)
686 def save(self, *args, **kwargs):
687 if self.date is None:
688 self.date = datetime.now()
689 super(NewsletterArticle, self).save(*args, **kwargs)
691 def __unicode__(self):
695 get_latest_by = 'date'
697 unique_together = (('newsletter', 'slug'),)
700 register_value_model(NewsletterArticle)
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')
716 def __unicode__(self):
720 ordering = ['-numbering']
721 unique_together = (('newsletter', 'numbering'),)
724 register_value_model(NewsletterIssue)
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'),
736 #: A :class:`ForeignKey` to the :class:`Newsletter` managed by this :class:`NewsletterView`.
737 newsletter = models.ForeignKey(Newsletter, related_name='newsletterviews')
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)
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:
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')
762 item_context_var = 'articles'
763 object_attr = 'newsletter'
765 def __unicode__(self):
766 return "NewsletterView for %s" % self.newsletter.__unicode__()
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)):
784 'year': str(obj.year).zfill(4),
785 'month': str(obj.month).zfill(2),
786 'day': str(obj.day).zfill(2)
788 return 'articles_by_day', [], kwargs
789 raise ViewCanNotProvideSubpath
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')
796 if self.issue_archive_page:
797 urlpatterns += patterns('',
798 url(r'^%s$' % self.issue_permalink_base, self.issue_archive_view, 'issue_archive')
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')))
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')
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)
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)
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)
824 urlpatterns += patterns('',
825 url(r'^%s/(?P<slug>[-\w]+)$' % self.article_permalink_base, self.article_view)
830 def get_context(self):
831 return {'newsletter': self.newsletter}
833 def get_article_queryset(self):
834 """Returns the default :class:`QuerySet` of :class:`NewsletterArticle` instances for the :class:`NewsletterView`."""
835 return self.newsletter.articles.all()
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()
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
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)
849 articles = articles.filter(date__month=month)
851 articles = articles.filter(date__day=day)
852 return articles, extra_context
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."""
857 issue = self.get_issue_queryset().get(numbering=numbering)
858 except NewsletterIssue.DoesNotExist:
860 context = extra_context or {}
861 context.update({'issue': issue})
862 return self.get_article_queryset().filter(issues=issue), context
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()
868 articles = articles.filter(date__year=year)
870 articles = articles.filter(date__month=month)
872 articles = articles.filter(date__day=day)
874 article = articles.get(slug=slug)
875 except NewsletterArticle.DoesNotExist:
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)
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:
886 context = self.get_context()
887 context.update(extra_context or {})
889 'issues': self.get_issue_queryset()
891 return self.issue_archive_page.render_to_response(request, extra_context=context)
893 def title(self, obj):
896 def item_title(self, item):
899 def item_description(self, item):
900 return item.full_text
902 def item_author_name(self, item):
903 authors = list(item.authors.all())
905 return "%s and %s" % (", ".join([author.get_full_name() for author in authors[:-1]]), authors[-1].get_full_name())
907 return authors[0].get_full_name()
911 def item_pubdate(self, item):
914 def item_categories(self, item):
915 return [tag.name for tag in item.tags.all()]