Tweaked admin and models. Added filters to manage navigation display.
[philo.git] / contrib / navigation / models.py
1 #encoding: utf-8
2 from django.core.exceptions import ValidationError
3 from django.core.urlresolvers import NoReverseMatch
4 from django.db import models
5 from philo.models import TreeEntity, JSONField, Node
6 from philo.validators import RedirectValidator
7
8 #from mptt.templatetags.mptt_tags import cache_tree_children
9
10
11 DEFAULT_NAVIGATION_DEPTH = 3
12
13
14 class NavigationManager(models.Manager):
15         
16         # Analagous to contenttypes, cache Navigation to avoid repeated lookups all over the place.
17         # Navigation will probably be used frequently.
18         _cache = {}
19         
20         def for_node(self, node):
21                 """
22                 Returns the set of Navigation objects for a given node's navigation. This
23                 will be the most recent set of defined hosted navigation among the node's
24                 ancestors. Lookups are cached so that subsequent lookups for the same node
25                 don't hit the database.
26                 
27                 TODO: Should this create the auto-generated navigation in "physical" form?
28                 """
29                 key = node.pk
30                 try:
31                         hosted_navigation = self.__class__._cache[self.db][key]
32                 except KeyError:
33                         # Find the most recent host!
34                         ancestors = node.get_ancestors(ascending=True, include_self=True).annotate(num_navigation=models.Count("hosted_navigation"))
35                         
36                         # Iterate down the ancestors until you find one that:
37                         # a) is cached, or
38                         # b) has hosted navigation.
39                         pks_to_cache = []
40                         host_node = None
41                         for ancestor in ancestors:
42                                 if ancestor.pk in self.__class__._cache[self.db] or ancestor.num_navigation > 0:
43                                         host_node = ancestor
44                                         break
45                                 else:
46                                         pks_to_cache.append(ancestor.pk)
47                         
48                         if host_node is None:
49                                 return self.none()
50                         
51                         if ancestor.pk not in self.__class__._cache[self.db]:
52                                 self.__class__._cache[self.db][ancestor.pk] = host_node.hosted_navigation.select_related('target_node')
53                         
54                         hosted_navigation = self.__class__._cache[self.db][ancestor.pk]
55                         
56                         # Cache the queryset instance for every pk that was passed over, as well.
57                         for pk in pks_to_cache:
58                                 self.__class__._cache[self.db][pk] = hosted_navigation
59                 
60                 return hosted_navigation
61         
62         def clear_cache(self, navigation=None):
63                 """
64                 Clear out the navigation cache. This needs to happen during database flushes
65                 or if a navigation entry is changed to prevent caching of outdated navigation information.
66                 
67                 TODO: call this method from update() and delete()!
68                 """
69                 if navigation is None:
70                         self.__class__._cache.clear()
71                 else:
72                         cache = self.__class__._cache[self.db]
73                         for pk in cache.keys():
74                                 for qs in cache[pk]:
75                                         if navigation in qs:
76                                                 cache.pop(pk)
77                                                 break
78                                         else:
79                                                 for instance in qs:
80                                                         if navigation.is_descendant(instance):
81                                                                 cache.pop(pk)
82                                                                 break
83                                                 # necessary?
84                                                 if pk not in cache:
85                                                         break
86
87
88 class Navigation(TreeEntity):
89         text = models.CharField(max_length=50)
90         
91         hosting_node = models.ForeignKey(Node, blank=True, null=True, related_name='hosted_navigation', help_text="Be part of this node's root navigation.")
92         
93         target_node = models.ForeignKey(Node, blank=True, null=True, related_name='targeting_navigation', help_text="Point to this node's url.")
94         url_or_subpath = models.CharField(max_length=200, validators=[RedirectValidator()], blank=True, help_text="Point to this url or, if a node is defined and accepts subpaths, this subpath of the node.")
95         reversing_parameters = JSONField(blank=True, help_text="If reversing parameters are defined, url_or_subpath will instead be interpreted as the view name to be reversed.")
96         
97         order = models.PositiveSmallIntegerField(blank=True, null=True)
98         depth = models.PositiveSmallIntegerField(blank=True, null=True, default=DEFAULT_NAVIGATION_DEPTH, help_text="For the root of a hosted tree, defines the depth of the tree. A blank depth will hide this section of navigation. Otherwise, depth is ignored.")
99         
100         def clean(self):
101                 # Should this be enforced? Not enforcing it would allow creation of "headers" in the navbar.
102                 if not self.target_node and not self.url_or_subpath:
103                         raise ValidationError("Either a target node or a url must be defined.")
104                 
105                 if self.reversing_parameters and (not self.url_or_subpath or not self.target_node):
106                         raise ValidationError("Reversing parameters require a view name and a target node.")
107                 
108                 try:
109                         self.get_target_url()
110                 except NoReverseMatch, e:
111                         raise ValidationError(e.message)
112         
113         def get_target_url(self):
114                 node = self.target_node
115                 if node is not None and node.accepts_subpath and self.url_or_subpath:
116                         if self.reversing_parameters is not None:
117                                 view_name = self.url_or_subpath
118                                 params = self.reversing_parameters
119                                 args = isinstance(params, list) and params or None
120                                 kwargs = isinstance(params, dict) and params or None
121                                 return node.view.reverse(view_name, args=args, kwargs=kwargs, node=node)
122                         else:
123                                 subpath = self.url_or_subpath
124                                 while subpath and subpath[0] == '/':
125                                         subpath = subpath[1:]
126                                 return '%s%s' % (node.get_absolute_url(), subpath)
127                 elif node is not None:
128                         return node.get_absolute_url()
129                 else:
130                         return self.url_or_subpath
131         target_url = property(get_target_url)
132         
133         def __unicode__(self):
134                 return self.get_path(field='text', pathsep=u' › ')
135         
136         # TODO: Add delete and save methods to handle cache clearing.
137         
138         class Meta:
139                 ordering = ['order']
140                 verbose_name_plural = 'navigation'