Initial implementation of a separate syndication contrib app based on penfield's...
[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.
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                         feed_reverse_name = "%s_feed" % reverse_name
72                         feed_view = http_not_acceptable(self.feed_view(get_items_attr, feed_reverse_name))
73                         feed_pattern = r'%s%s%s$' % (base, (base and base[-1] != "^") and "/" or "", self.feed_suffix)
74                         urlpatterns += patterns('',
75                                 url(feed_pattern, feed_view, name=feed_reverse_name),
76                         )
77                 urlpatterns += patterns('',
78                         url(r"%s$" % base, self.page_view(get_items_attr, page_attr), name=reverse_name)
79                 )
80                 return urlpatterns
81         
82         def get_object(self, request, **kwargs):
83                 """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."""
84                 return getattr(self, self.object_attr)
85         
86         def feed_view(self, get_items_attr, reverse_name):
87                 """
88                 Returns a view function that renders a list of items as a feed.
89                 
90                 :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.
91                 :param reverse_name: The name which can be used reverse this feed using the :class:`FeedView` as the urlconf.
92                 
93                 :returns: A view function that renders a list of items as a feed.
94                 
95                 """
96                 get_items = get_items_attr if callable(get_items_attr) else getattr(self, get_items_attr)
97                 
98                 def inner(request, extra_context=None, *args, **kwargs):
99                         obj = self.get_object(request, *args, **kwargs)
100                         feed = self.get_feed(obj, request, reverse_name)
101                         items, xxx = get_items(request, extra_context=extra_context, *args, **kwargs)
102                         self.populate_feed(feed, items, request)
103                         
104                         response = HttpResponse(mimetype=feed.mime_type)
105                         feed.write(response, 'utf-8')
106                         return response
107                 
108                 return inner
109         
110         def page_view(self, get_items_attr, page_attr):
111                 """
112                 :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.
113                 :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``.
114                 
115                 :returns: A view function that renders a list of items as an :class:`HttpResponse`.
116                 
117                 """
118                 get_items = get_items_attr if callable(get_items_attr) else getattr(self, get_items_attr)
119                 page = page_attr if isinstance(page_attr, Page) else getattr(self, page_attr)
120                 
121                 def inner(request, extra_context=None, *args, **kwargs):
122                         items, extra_context = get_items(request, extra_context=extra_context, *args, **kwargs)
123                         items, item_context = self.process_page_items(request, items)
124                         
125                         context = self.get_context()
126                         context.update(extra_context or {})
127                         context.update(item_context or {})
128                         
129                         return page.render_to_response(request, extra_context=context)
130                 return inner
131         
132         def process_page_items(self, request, items):
133                 """
134                 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.
135                 
136                 """
137                 item_context = {
138                         self.item_context_var: items
139                 }
140                 return items, item_context
141         
142         def get_feed_type(self, request):
143                 """
144                 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`.
145                 
146                 """
147                 feed_type = registry.get(self.feed_type, DEFAULT_FEED)
148                 mt = feed_type.mime_type
149                 accept = request.META.get('HTTP_ACCEPT')
150                 if accept and mt not in accept and "*/*" not in accept and "%s/*" % mt.split("/")[0] not in accept:
151                         # Wups! They aren't accepting the chosen format. Is there another format we can use?
152                         feed_type = None
153                         accepted_mts = dict([(obj.mime_type, obj) for obj in registry.values()])
154                         if mimeparse:
155                                 mt = mimeparse.best_match(accepted_mts.keys(), accept)
156                                 if mt:
157                                         feed_type = accepted_mts[mt]
158                         else:
159                                 for mt in accepted_mts:
160                                         if mt in accept or "%s/*" % mt.split("/")[0] in accept:
161                                                 feed_type = accepted_mts[mt]
162                                                 break
163                         if not feed_type:
164                                 raise HttpNotAcceptable
165                 return feed_type
166         
167         def get_feed(self, obj, request, reverse_name):
168                 """
169                 Returns an unpopulated :class:`django.utils.feedgenerator.DefaultFeed` object for this object.
170                 
171                 """
172                 try:
173                         current_site = Site.objects.get_current()
174                 except Site.DoesNotExist:
175                         current_site = RequestSite(request)
176                 
177                 feed_type = self.get_feed_type(request)
178                 node = request.node
179                 link = node.get_absolute_url(with_domain=True, request=request, secure=request.is_secure())
180                 
181                 feed = feed_type(
182                         title = self.__get_dynamic_attr('title', obj),
183                         subtitle = self.__get_dynamic_attr('subtitle', obj),
184                         link = link,
185                         description = self.__get_dynamic_attr('description', obj),
186                         language = settings.LANGUAGE_CODE.decode(),
187                         feed_url = add_domain(
188                                 current_site.domain,
189                                 self.__get_dynamic_attr('feed_url', obj) or node.construct_url(node._subpath, with_domain=True, request=request, secure=request.is_secure()),
190                                 request.is_secure()
191                         ),
192                         author_name = self.__get_dynamic_attr('author_name', obj),
193                         author_link = self.__get_dynamic_attr('author_link', obj),
194                         author_email = self.__get_dynamic_attr('author_email', obj),
195                         categories = self.__get_dynamic_attr('categories', obj),
196                         feed_copyright = self.__get_dynamic_attr('feed_copyright', obj),
197                         feed_guid = self.__get_dynamic_attr('feed_guid', obj),
198                         ttl = self.__get_dynamic_attr('ttl', obj),
199                         **self.feed_extra_kwargs(obj)
200                 )
201                 return feed
202         
203         def populate_feed(self, feed, items, request):
204                 """Populates a :class:`django.utils.feedgenerator.DefaultFeed` instance as is returned by :meth:`get_feed` with the passed-in ``items``."""
205                 if self.item_title_template:
206                         title_template = DjangoTemplate(self.item_title_template.code)
207                 else:
208                         title_template = None
209                 if self.item_description_template:
210                         description_template = DjangoTemplate(self.item_description_template.code)
211                 else:
212                         description_template = None
213                 
214                 node = request.node
215                 try:
216                         current_site = Site.objects.get_current()
217                 except Site.DoesNotExist:
218                         current_site = RequestSite(request)
219                 
220                 if self.feed_length is not None:
221                         items = items[:self.feed_length]
222                 
223                 for item in items:
224                         if title_template is not None:
225                                 title = title_template.render(RequestContext(request, {'obj': item}))
226                         else:
227                                 title = self.__get_dynamic_attr('item_title', item)
228                         if description_template is not None:
229                                 description = description_template.render(RequestContext(request, {'obj': item}))
230                         else:
231                                 description = self.__get_dynamic_attr('item_description', item)
232                         
233                         link = node.construct_url(self.reverse(obj=item), with_domain=True, request=request, secure=request.is_secure())
234                         
235                         enc = None
236                         enc_url = self.__get_dynamic_attr('item_enclosure_url', item)
237                         if enc_url:
238                                 enc = feedgenerator.Enclosure(
239                                         url = smart_unicode(add_domain(
240                                                         current_site.domain,
241                                                         enc_url,
242                                                         request.is_secure()
243                                         )),
244                                         length = smart_unicode(self.__get_dynamic_attr('item_enclosure_length', item)),
245                                         mime_type = smart_unicode(self.__get_dynamic_attr('item_enclosure_mime_type', item))
246                                 )
247                         author_name = self.__get_dynamic_attr('item_author_name', item)
248                         if author_name is not None:
249                                 author_email = self.__get_dynamic_attr('item_author_email', item)
250                                 author_link = self.__get_dynamic_attr('item_author_link', item)
251                         else:
252                                 author_email = author_link = None
253                         
254                         pubdate = self.__get_dynamic_attr('item_pubdate', item)
255                         if pubdate and not pubdate.tzinfo:
256                                 ltz = tzinfo.LocalTimezone(pubdate)
257                                 pubdate = pubdate.replace(tzinfo=ltz)
258                         
259                         feed.add_item(
260                                 title = title,
261                                 link = link,
262                                 description = description,
263                                 unique_id = self.__get_dynamic_attr('item_guid', item, link),
264                                 enclosure = enc,
265                                 pubdate = pubdate,
266                                 author_name = author_name,
267                                 author_email = author_email,
268                                 author_link = author_link,
269                                 categories = self.__get_dynamic_attr('item_categories', item),
270                                 item_copyright = self.__get_dynamic_attr('item_copyright', item),
271                                 **self.item_extra_kwargs(item)
272                         )
273         
274         def __get_dynamic_attr(self, attname, obj, default=None):
275                 try:
276                         attr = getattr(self, attname)
277                 except AttributeError:
278                         return default
279                 if callable(attr):
280                         # Check func_code.co_argcount rather than try/excepting the
281                         # function and catching the TypeError, because something inside
282                         # the function may raise the TypeError. This technique is more
283                         # accurate.
284                         if hasattr(attr, 'func_code'):
285                                 argcount = attr.func_code.co_argcount
286                         else:
287                                 argcount = attr.__call__.func_code.co_argcount
288                         if argcount == 2: # one argument is 'self'
289                                 return attr(obj)
290                         else:
291                                 return attr()
292                 return attr
293         
294         def feed_extra_kwargs(self, obj):
295                 """Returns an extra keyword arguments dictionary that is used when initializing the feed generator."""
296                 return {}
297         
298         def item_extra_kwargs(self, item):
299                 """Returns an extra keyword arguments dictionary that is used with the `add_item` call of the feed generator."""
300                 return {}
301         
302         def item_title(self, item):
303                 return escape(force_unicode(item))
304         
305         def item_description(self, item):
306                 return force_unicode(item)
307         
308         class Meta:
309                 abstract=True