Tweaked FeedView to more correctly generate links vs feed urls.
[philo.git] / philo / contrib / syndication / models.py
1 from django.conf import settings
2 from django.conf.urls.defaults import url, patterns, include
3 from django.contrib.sites.models import Site, RequestSite
4 from django.contrib.syndication.views import add_domain
5 from django.db import models
6 from django.http import HttpResponse
7 from django.template import RequestContext, Template as DjangoTemplate
8 from django.utils import feedgenerator, tzinfo
9 from django.utils.encoding import smart_unicode, force_unicode
10 from django.utils.html import escape
11
12 from philo.contrib.syndication.exceptions import HttpNotAcceptable
13 from philo.contrib.syndication.feeds import registry, DEFAULT_FEED
14 from philo.contrib.syndication.middleware import http_not_acceptable
15 from philo.models import Page, Template, MultiView
16
17 try:
18         import mimeparse
19 except:
20         mimeparse = None
21
22
23 class FeedView(MultiView):
24         """
25         :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>`_.
26         
27         """
28         #: The type of feed which should be served by the :class:`FeedView`.
29         feed_type = models.CharField(max_length=50, choices=registry.choices, default=registry.get_slug(DEFAULT_FEED))
30         #: The suffix which will be appended to a page URL for a feed of its items. Default: "feed"
31         feed_suffix = models.CharField(max_length=255, blank=False, default="feed")
32         #: A :class:`BooleanField` - whether or not feeds are enabled.
33         feeds_enabled = models.BooleanField(default=True)
34         #: 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.
35         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.")
36         
37         #: A :class:`ForeignKey` to a :class:`.Template` which will be used to render the title of each item in the feed if provided.
38         item_title_template = models.ForeignKey(Template, blank=True, null=True, related_name="%(app_label)s_%(class)s_title_related")
39         #: A :class:`ForeignKey` to a :class:`.Template` which will be used to render the description of each item in the feed if provided.
40         item_description_template = models.ForeignKey(Template, blank=True, null=True, related_name="%(app_label)s_%(class)s_description_related")
41         
42         #: The name of the context variable to be populated with the items managed by the :class:`FeedView`.
43         item_context_var = 'items'
44         #: The attribute on a subclass of :class:`FeedView` which will contain the main object of a feed (such as a :class:`Blog`.)
45         object_attr = 'object'
46         
47         #: A description of the feeds served by the :class:`FeedView`. This is a required part of the :class:`django.contrib.syndication.view.Feed` API.
48         description = ""
49         
50         def feed_patterns(self, base, get_items_attr, page_attr, reverse_name):
51                 """
52                 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. In addition to ``base`` and ``base`` + :attr:`feed_suffix`, patterns will be provided for each registered feed type as ``base`` + ``slug``.
53                 
54                 :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.
55                 :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`.
56                 :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``.
57                 :param reverse_name: The string which is considered the "name" of the view function returned by :meth:`page_view` for the given parameters.
58                 :returns: Patterns suitable for use in urlpatterns.
59                 
60                 Example::
61                 
62                         @property
63                         def urlpatterns(self):
64                                 urlpatterns = self.feed_patterns(r'^', 'get_all_entries', 'index_page', 'index')
65                                 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')
66                                 return urlpatterns
67                 
68                 """
69                 urlpatterns = patterns('')
70                 if self.feeds_enabled:
71                         suffixes = [(self.feed_suffix, None)] + [(slug, slug) for slug in registry]
72                         for suffix, feed_type in suffixes:
73                                 feed_view = http_not_acceptable(self.feed_view(get_items_attr, reverse_name, feed_type))
74                                 feed_pattern = r'%s%s%s$' % (base, "/" if base and base[-1] != "^" else "", suffix)
75                                 urlpatterns += patterns('',
76                                         url(feed_pattern, feed_view, name="%s_%s" % (reverse_name, suffix)),
77                                 )
78                 urlpatterns += patterns('',
79                         url(r"%s$" % base, self.page_view(get_items_attr, page_attr), name=reverse_name)
80                 )
81                 return urlpatterns
82         
83         def get_object(self, request, **kwargs):
84                 """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."""
85                 return getattr(self, self.object_attr)
86         
87         def feed_view(self, get_items_attr, reverse_name, feed_type=None):
88                 """
89                 Returns a view function that renders a list of items as a feed.
90                 
91                 :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.
92                 :param reverse_name: The name which can be used reverse the page for this feed using the :class:`FeedView` as the urlconf.
93                 :param feed_type: The slug used to render the feed class which will be used by the returned view function.
94                 
95                 :returns: A view function that renders a list of items as a feed.
96                 
97                 """
98                 get_items = get_items_attr if callable(get_items_attr) else getattr(self, get_items_attr)
99                 
100                 def inner(request, extra_context=None, *args, **kwargs):
101                         obj = self.get_object(request, *args, **kwargs)
102                         feed = self.get_feed(obj, request, reverse_name, feed_type, *args, **kwargs)
103                         items, xxx = get_items(request, extra_context=extra_context, *args, **kwargs)
104                         self.populate_feed(feed, items, request)
105                         
106                         response = HttpResponse(mimetype=feed.mime_type)
107                         feed.write(response, 'utf-8')
108                         return response
109                 
110                 return inner
111         
112         def page_view(self, get_items_attr, page_attr):
113                 """
114                 :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.
115                 :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``.
116                 
117                 :returns: A view function that renders a list of items as an :class:`HttpResponse`.
118                 
119                 """
120                 get_items = get_items_attr if callable(get_items_attr) else getattr(self, get_items_attr)
121                 page = page_attr if isinstance(page_attr, Page) else getattr(self, page_attr)
122                 
123                 def inner(request, extra_context=None, *args, **kwargs):
124                         items, extra_context = get_items(request, extra_context=extra_context, *args, **kwargs)
125                         items, item_context = self.process_page_items(request, items)
126                         
127                         context = self.get_context()
128                         context.update(extra_context or {})
129                         context.update(item_context or {})
130                         
131                         return page.render_to_response(request, extra_context=context)
132                 return inner
133         
134         def process_page_items(self, request, items):
135                 """
136                 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.
137                 
138                 """
139                 item_context = {
140                         self.item_context_var: items
141                 }
142                 return items, item_context
143         
144         def get_feed_type(self, request, feed_type=None):
145                 """
146                 If ``feed_type`` is not ``None``, returns the corresponding class from the registry or reises :exc:`.HttpNotAcceptable`. Otherwise, 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`, raises :exc:`.HttpNotAcceptable`.
147                 
148                 """
149                 if feed_type is not None:
150                         feed_type = registry[feed_type]
151                         loose = False
152                 else:
153                         feed_type = registry.get(self.feed_type, DEFAULT_FEED)
154                         loose = True
155                 mt = feed_type.mime_type
156                 accept = request.META.get('HTTP_ACCEPT')
157                 if accept and mt not in accept and "*/*" not in accept and "%s/*" % mt.split("/")[0] not in accept:
158                         # Wups! They aren't accepting the chosen format.
159                         feed_type = None
160                         if loose:
161                                 # Is there another format we can use?
162                                 accepted_mts = dict([(obj.mime_type, obj) for obj in registry.values()])
163                                 if mimeparse:
164                                         mt = mimeparse.best_match(accepted_mts.keys(), accept)
165                                         if mt:
166                                                 feed_type = accepted_mts[mt]
167                                 else:
168                                         for mt in accepted_mts:
169                                                 if mt in accept or "%s/*" % mt.split("/")[0] in accept:
170                                                         feed_type = accepted_mts[mt]
171                                                         break
172                         if not feed_type:
173                                 raise HttpNotAcceptable
174                 return feed_type
175         
176         def get_feed(self, obj, request, reverse_name, feed_type=None, *args, **kwargs):
177                 """
178                 Returns an unpopulated :class:`django.utils.feedgenerator.DefaultFeed` object for this object.
179                 
180                 :param obj: The object for which the feed should be generated.
181                 :param request: The current request.
182                 :param reverse_name: The name which can be used to reverse the URL of the page corresponding to this feed.
183                 :param feed_type: The slug used to register the feed class that will be instantiated and returned.
184                 
185                 :returns: An instance of the feed class registered as ``feed_type``, falling back to :attr:`feed_type` if ``feed_type`` is ``None``.
186                 
187                 """
188                 try:
189                         current_site = Site.objects.get_current()
190                 except Site.DoesNotExist:
191                         current_site = RequestSite(request)
192                 
193                 feed_type = self.get_feed_type(request, feed_type)
194                 node = request.node
195                 link = node.construct_url(self.reverse(reverse_name, args=args, kwargs=kwargs), with_domain=True, request=request, secure=request.is_secure())
196                 
197                 feed = feed_type(
198                         title = self.__get_dynamic_attr('title', obj),
199                         subtitle = self.__get_dynamic_attr('subtitle', obj),
200                         link = link,
201                         description = self.__get_dynamic_attr('description', obj),
202                         language = settings.LANGUAGE_CODE.decode(),
203                         feed_url = add_domain(
204                                 current_site.domain,
205                                 self.__get_dynamic_attr('feed_url', obj) or node.construct_url(self.reverse("%s_%s" % (reverse_name, registry.get_slug(feed_type)), args=args, kwargs=kwargs), with_domain=True, request=request, secure=request.is_secure()),
206                                 request.is_secure()
207                         ),
208                         author_name = self.__get_dynamic_attr('author_name', obj),
209                         author_link = self.__get_dynamic_attr('author_link', obj),
210                         author_email = self.__get_dynamic_attr('author_email', obj),
211                         categories = self.__get_dynamic_attr('categories', obj),
212                         feed_copyright = self.__get_dynamic_attr('feed_copyright', obj),
213                         feed_guid = self.__get_dynamic_attr('feed_guid', obj),
214                         ttl = self.__get_dynamic_attr('ttl', obj),
215                         **self.feed_extra_kwargs(obj)
216                 )
217                 return feed
218         
219         def populate_feed(self, feed, items, request):
220                 """Populates a :class:`django.utils.feedgenerator.DefaultFeed` instance as is returned by :meth:`get_feed` with the passed-in ``items``."""
221                 if self.item_title_template:
222                         title_template = DjangoTemplate(self.item_title_template.code)
223                 else:
224                         title_template = None
225                 if self.item_description_template:
226                         description_template = DjangoTemplate(self.item_description_template.code)
227                 else:
228                         description_template = None
229                 
230                 node = request.node
231                 try:
232                         current_site = Site.objects.get_current()
233                 except Site.DoesNotExist:
234                         current_site = RequestSite(request)
235                 
236                 if self.feed_length is not None:
237                         items = items[:self.feed_length]
238                 
239                 for item in items:
240                         if title_template is not None:
241                                 title = title_template.render(RequestContext(request, {'obj': item}))
242                         else:
243                                 title = self.__get_dynamic_attr('item_title', item)
244                         if description_template is not None:
245                                 description = description_template.render(RequestContext(request, {'obj': item}))
246                         else:
247                                 description = self.__get_dynamic_attr('item_description', item)
248                         
249                         link = node.construct_url(self.reverse(obj=item), with_domain=True, request=request, secure=request.is_secure())
250                         
251                         enc = None
252                         enc_url = self.__get_dynamic_attr('item_enclosure_url', item)
253                         if enc_url:
254                                 enc = feedgenerator.Enclosure(
255                                         url = smart_unicode(add_domain(
256                                                         current_site.domain,
257                                                         enc_url,
258                                                         request.is_secure()
259                                         )),
260                                         length = smart_unicode(self.__get_dynamic_attr('item_enclosure_length', item)),
261                                         mime_type = smart_unicode(self.__get_dynamic_attr('item_enclosure_mime_type', item))
262                                 )
263                         author_name = self.__get_dynamic_attr('item_author_name', item)
264                         if author_name is not None:
265                                 author_email = self.__get_dynamic_attr('item_author_email', item)
266                                 author_link = self.__get_dynamic_attr('item_author_link', item)
267                         else:
268                                 author_email = author_link = None
269                         
270                         pubdate = self.__get_dynamic_attr('item_pubdate', item)
271                         if pubdate and not pubdate.tzinfo:
272                                 ltz = tzinfo.LocalTimezone(pubdate)
273                                 pubdate = pubdate.replace(tzinfo=ltz)
274                         
275                         feed.add_item(
276                                 title = title,
277                                 link = link,
278                                 description = description,
279                                 unique_id = self.__get_dynamic_attr('item_guid', item, link),
280                                 enclosure = enc,
281                                 pubdate = pubdate,
282                                 author_name = author_name,
283                                 author_email = author_email,
284                                 author_link = author_link,
285                                 categories = self.__get_dynamic_attr('item_categories', item),
286                                 item_copyright = self.__get_dynamic_attr('item_copyright', item),
287                                 **self.item_extra_kwargs(item)
288                         )
289         
290         def __get_dynamic_attr(self, attname, obj, default=None):
291                 try:
292                         attr = getattr(self, attname)
293                 except AttributeError:
294                         return default
295                 if callable(attr):
296                         # Check func_code.co_argcount rather than try/excepting the
297                         # function and catching the TypeError, because something inside
298                         # the function may raise the TypeError. This technique is more
299                         # accurate.
300                         if hasattr(attr, 'func_code'):
301                                 argcount = attr.func_code.co_argcount
302                         else:
303                                 argcount = attr.__call__.func_code.co_argcount
304                         if argcount == 2: # one argument is 'self'
305                                 return attr(obj)
306                         else:
307                                 return attr()
308                 return attr
309         
310         def feed_extra_kwargs(self, obj):
311                 """Returns an extra keyword arguments dictionary that is used when initializing the feed generator."""
312                 return {}
313         
314         def item_extra_kwargs(self, item):
315                 """Returns an extra keyword arguments dictionary that is used with the `add_item` call of the feed generator."""
316                 return {}
317         
318         def item_title(self, item):
319                 return escape(force_unicode(item))
320         
321         def item_description(self, item):
322                 return force_unicode(item)
323         
324         class Meta:
325                 abstract=True