app/soc/logic/site/page.py
changeset 517 661ab830e921
parent 516 ec1dcd70b97e
child 518 d9d31d316a74
equal deleted inserted replaced
516:ec1dcd70b97e 517:661ab830e921
     1 #!/usr/bin/python2.5
       
     2 #
       
     3 # Copyright 2008 the Melange authors.
       
     4 #
       
     5 # Licensed under the Apache License, Version 2.0 (the "License");
       
     6 # you may not use this file except in compliance with the License.
       
     7 # You may obtain a copy of the License at
       
     8 #
       
     9 #   http://www.apache.org/licenses/LICENSE-2.0
       
    10 #
       
    11 # Unless required by applicable law or agreed to in writing, software
       
    12 # distributed under the License is distributed on an "AS IS" BASIS,
       
    13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
       
    14 # See the License for the specific language governing permissions and
       
    15 # limitations under the License.
       
    16 
       
    17 """Page properties, used to generate sidebar menus, urlpatterns, etc.
       
    18 """
       
    19 
       
    20 __authors__ = [
       
    21   '"Todd Larsen" <tlarsen@google.com>',
       
    22   ]
       
    23 
       
    24 
       
    25 import copy
       
    26 import re
       
    27 
       
    28 from django.conf.urls import defaults
       
    29 
       
    30 from python25src import urllib
       
    31 
       
    32 from soc.logic import menu
       
    33 from soc.logic.no_overwrite_sorted_dict import NoOverwriteSortedDict
       
    34 
       
    35 
       
    36 class Url:
       
    37   """The components of a Django URL pattern.
       
    38   """
       
    39    
       
    40   def __init__(self, regex, view, kwargs=None, name=None, prefix=''):
       
    41     """Collects Django urlpatterns info into a simple object.
       
    42     
       
    43     The arguments to this constructor correspond directly to the items in
       
    44     the urlpatterns tuple, which also correspond to the parameters of
       
    45     django.conf.urls.defaults.url().
       
    46     
       
    47     Args:
       
    48       regex: a Django URL regex pattern, which, for obvious reason, must
       
    49         be unique
       
    50       view: a Django view, either a string or a callable; if a callable,
       
    51         a unique 'name' string must be supplied
       
    52       kwargs: optional dict of extra arguments passed to the view
       
    53         function as keyword arguments, which is copy.deepcopy()'d;
       
    54         default is None, which supplies an empty dict {}
       
    55       name: optional name of the view; used instead of 'view' if supplied;
       
    56         the 'name' or 'view' string, whichever is used, must be unique
       
    57         amongst *all* Url objects supplied to a Page object
       
    58       prefix: optional view prefix
       
    59     """
       
    60     self.regex = regex
       
    61     self.view = view
       
    62     
       
    63     if kwargs:
       
    64       self.kwargs = copy.deepcopy(kwargs)
       
    65     else:
       
    66       self.kwargs = {}
       
    67 
       
    68     self.name = name
       
    69     self.prefix = prefix
       
    70 
       
    71   def makeDjangoUrl(self, **extra_kwargs):
       
    72     """Returns a Django url() used by urlpatterns, or None if not a view.
       
    73     """
       
    74     if not self.view:
       
    75       return None
       
    76 
       
    77     kwargs = copy.deepcopy(self.kwargs)
       
    78     kwargs.update(extra_kwargs)
       
    79     return defaults.url(self.regex, self.view, kwargs=kwargs,
       
    80                         name=self.name, prefix=self.prefix)
       
    81 
       
    82   _STR_FMT = '''%(indent)sregex: %(regex)s
       
    83 %(indent)sview: %(view)s
       
    84 %(indent)skwargs: %(kwargs)s
       
    85 %(indent)sname: %(name)s
       
    86 %(indent)sprefix: %(prefix)s
       
    87 '''
       
    88 
       
    89   def asIndentedStr(self, indent=''):
       
    90     """Returns an indented string representation useful for logging.
       
    91     
       
    92     Args:
       
    93       indent: an indentation string that is prepended to each line present
       
    94         in the multi-line string returned by this method.
       
    95     """
       
    96     return self._STR_FMT % {'indent': indent, 'regex': self.regex,
       
    97                             'view': self.view, 'kwargs': self.kwargs,
       
    98                             'name': self.name, 'prefix': self.prefix}
       
    99 
       
   100   def __str__(self):
       
   101     """Returns a string representation useful for logging.
       
   102     """
       
   103     return self.asIndentedStr()
       
   104 
       
   105 
       
   106 class Page:
       
   107   """An abstraction that combines a Django view with sidebar menu info.
       
   108   """
       
   109   
       
   110   def __init__(self, url, long_name, short_name=None, selected=False,
       
   111                annotation=None, parent=None, link_url=None,
       
   112                in_breadcrumb=True, force_in_menu=False):
       
   113     """Initializes the menu item attributes from supplied arguments.
       
   114     
       
   115     Args:
       
   116       url: a Url object
       
   117       long_name: title of the Page
       
   118       short_name: optional menu item and breadcrumb name; default is
       
   119         None, in which case long_name is used
       
   120       selected: Boolean indicating if this menu item is selected;
       
   121         default is False
       
   122       annotation: optional annotation associated with the menu item;
       
   123         default is None
       
   124       parent: optional Page that is the logical "parent" of this page;
       
   125         used to construct hierarchical menus and breadcrumb trails
       
   126       link_url: optional alternate URL link; if supplied, it is returned
       
   127         by makeLinkUrl(); default is None, and makeLinkUrl() attempts to
       
   128         create a URL link from url.regex 
       
   129       in_breadcrumb: if True, the Page appears in breadcrumb trails;
       
   130         default is True
       
   131       force_in_menu: if True, the Page appears in menus even if it does
       
   132         not have a usable link_url; default is False, which excludes
       
   133         the Page if makeLinkUrl() returns None
       
   134     """
       
   135     self.url = url
       
   136     self.long_name = long_name
       
   137     self.annotation = annotation
       
   138     self.in_breadcrumb = in_breadcrumb
       
   139     self.force_in_menu = force_in_menu
       
   140     self.link_url = link_url
       
   141     self.selected = selected
       
   142     self.parent = parent
       
   143 
       
   144     # create ordered, unique mappings of URLs and view names to Pages
       
   145     self.child_by_urls = NoOverwriteSortedDict()
       
   146     self.child_by_views = NoOverwriteSortedDict()
       
   147     
       
   148     if not short_name:
       
   149       short_name = long_name
       
   150       
       
   151     self.short_name = short_name
       
   152     
       
   153     if parent:
       
   154       # tell parent Page about parent <- child relationship
       
   155       parent.addChild(self)
       
   156       
       
   157     # TODO(tlarsen): build some sort of global Page dictionary to detect
       
   158     #   collisions sooner and to make global queries from URLs to pages
       
   159     #   and views to Pages possible (without requiring a recursive search)
       
   160 
       
   161   def getChildren(self):
       
   162     """Returns an iterator over any child Pages 
       
   163     """
       
   164     for page in self.child_by_views.itervalues():
       
   165       yield page
       
   166 
       
   167   children = property(getChildren)
       
   168 
       
   169   def getChild(self, url=None, regex=None, view=None,
       
   170                name=None, prefix=None, request_path=None):
       
   171     """Returns a child Page if one can be identified; or None otherwise.
       
   172     
       
   173     All of the parameters to this method are optional, but at least one
       
   174     must be supplied to return anything other than None.  The parameters
       
   175     are tried in the order they are listed in the "Args:" section, and
       
   176     this method exits on the first "match".
       
   177     
       
   178     Args:
       
   179       url: a Url object, used to overwrite the regex, view, name, and
       
   180         prefix parameters if present; default is None
       
   181       regex: a regex pattern string, used to return the associated
       
   182         child Page
       
   183       view: a view string, used to return the associated child Page
       
   184       name: a name string, used to return the associated child Page
       
   185       prefix: (currently unused, see TODO below in code)
       
   186       request_path: optional HTTP request path string (request.path)
       
   187         with no query arguments
       
   188     """
       
   189     # this code is yucky; there is probably a better way...
       
   190     if url:
       
   191       regex = url.regex
       
   192       view = url.view
       
   193       name = url.name
       
   194       prefix = url.prefix
       
   195 
       
   196     if regex in self.child_by_urls:
       
   197       # regex supplied and Page found, so return that Page
       
   198       return self.child_by_urls[regex][0]
       
   199   
       
   200     # TODO(tlarsen): make this work correctly with prefixes
       
   201 
       
   202     if view in self.child_views:
       
   203       # view supplied and Page found, so return that Page
       
   204       return self.child_by_views[view]
       
   205   
       
   206     if name in self.child_views:
       
   207       # name supplied and Page found, so return that Page
       
   208       return self.child_by_views[name]
       
   209 
       
   210     if request_path.startswith('/'):
       
   211       request_path = request_path[1:]
       
   212 
       
   213     # attempt to match the HTTP request path with a Django URL pattern
       
   214     for pattern, (page, regex) in self.child_by_urls:
       
   215       if regex.match(request_path):
       
   216         return page
       
   217 
       
   218     return None
       
   219 
       
   220   def addChild(self, page):
       
   221     """Adds a unique Page as a child Page of this parent.
       
   222     
       
   223     Raises:
       
   224       ValueError if page.url.regex is not a string.
       
   225       ValueError if page.url.view is not a string.
       
   226       ValueError if page.url.name is supplied but is not a string.
       
   227       KeyError if page.url.regex is already associated with another Page.
       
   228       KeyError if page.url.view/name is already associated with another Page.
       
   229     """
       
   230     # TODO(tlarsen): see also TODO in __init__() about global Page dictionary
       
   231 
       
   232     url = page.url
       
   233     
       
   234     if url.regex:
       
   235       if not isinstance(url.regex, basestring):
       
   236         raise ValueError('"regex" must be a string, not a compiled regex')
       
   237 
       
   238       # TODO(tlarsen): see if Django has some way exposed in its API to get
       
   239       #   the view name from the request path matched against urlpatterns;
       
   240       #   if so, there would be no need for child_by_urls, because the
       
   241       #   request path could be converted for us by Django into a view/name,
       
   242       #   and we could just use child_by_views with that string instead
       
   243       self.child_by_urls[url.regex] = (page, re.compile(url.regex))
       
   244     # else: NonUrl does not get indexed by regex, because it has none
       
   245 
       
   246     # TODO(tlarsen): make this work correctly if url has a prefix
       
   247     #   (not sure how to make this work with include() views...)
       
   248     if url.name:
       
   249       if not isinstance(url.name, basestring):
       
   250         raise ValueError('"name" must be a string if it is supplied')
       
   251 
       
   252       view = url.name
       
   253     elif isinstance(url.view, basestring):
       
   254       view = url.view
       
   255     else:
       
   256       raise ValueError('"view" must be a string if "name" is not supplied')
       
   257 
       
   258     self.child_by_views[view] = page
       
   259 
       
   260   def delChild(self, url=None, regex=None, view=None, name=None,
       
   261                prefix=None):
       
   262     """Removes a child Page if one can be identified.
       
   263     
       
   264     All of the parameters to this method are optional, but at least one
       
   265     must be supplied in order to remove a child Page.  The parameters
       
   266     are tried in the order they are listed in the "Args:" section, and
       
   267     this method uses the first "match".
       
   268     
       
   269     Args:
       
   270       url: a Url object, used to overwrite the regex, view, name, and
       
   271         prefix parameters if present; default is None
       
   272       regex: a regex pattern string, used to remove the associated
       
   273         child Page
       
   274       view: a view string, used to remove the associated child Page
       
   275       name: a name string, used to remove the associated child Page
       
   276       prefix: (currently unused, see TODO below in code)
       
   277       
       
   278     Raises:
       
   279       KeyError if the child Page could not be definitively identified in
       
   280       order to delete it.
       
   281     """
       
   282     # this code is yucky; there is probably a better way...
       
   283     if url:
       
   284       regex = url.regex
       
   285       view = url.view
       
   286       name = url.name
       
   287       prefix = url.prefix
       
   288 
       
   289     # try to find page by regex, view, or name, in turn
       
   290     if regex in self.child_by_urls:
       
   291       url = self.child_by_urls[regex][0].url
       
   292       view = url.view
       
   293       name = url.name
       
   294       prefix = url.prefix
       
   295     elif view in self.child_views:
       
   296       # TODO(tlarsen): make this work correctly with prefixes
       
   297       regex = self.child_by_views[view].url.regex
       
   298     elif name in self.child_views:
       
   299       regex = self.child_by_views[name].url.regex
       
   300 
       
   301     if regex:
       
   302       # regex must refer to an existing Page at this point
       
   303       del self.child_urls[regex]
       
   304 
       
   305     if not isinstance(view, basestring):
       
   306       # use name if view is callable() or None, etc.
       
   307       view = name
       
   308 
       
   309     # TODO(tlarsen): make this work correctly with prefixes
       
   310     del self.child_by_views[view]
       
   311     
       
   312 
       
   313   def makeLinkUrl(self):
       
   314     """Makes a URL link suitable for <A HREF> use.
       
   315     
       
   316     Returns:
       
   317       self.link_url if link_url was supplied to the __init__() constructor
       
   318         and it is a non-False value
       
   319        -OR-
       
   320       a suitable URL extracted from the url.regex, if possible
       
   321        -OR-
       
   322       None if url.regex contains quotable characters that have not already
       
   323         been quoted (that is, % is left untouched, so quote suspect
       
   324         characters in url.regex that would otherwise be quoted)
       
   325     """
       
   326     if self.link_url:
       
   327       return self.link_url
       
   328 
       
   329     link = self.url.regex
       
   330     
       
   331     if not link:
       
   332       return None
       
   333 
       
   334     if link.startswith('^'):
       
   335       link = link[1:]
       
   336     
       
   337     if link.endswith('$'):
       
   338       link = link[:-1]
       
   339 
       
   340     if not link.startswith('/'):
       
   341       link = '/' + link
       
   342     
       
   343     # path separators and already-quoted characters are OK
       
   344     if link != urllib.quote(link, safe='/%'):
       
   345       return None
       
   346 
       
   347     return link
       
   348 
       
   349   def makeMenuItem(self):
       
   350     """Returns a menu.MenuItem for the Page (and any child Pages).
       
   351     """
       
   352     child_items = []
       
   353     
       
   354     for child in self.children:
       
   355       child_item = child.makeMenuItem()
       
   356       if child_item:
       
   357         child_items.append(child_item)
       
   358     
       
   359     if child_items:
       
   360       sub_menu = menu.Menu(items=child_items)
       
   361     else:
       
   362       sub_menu = None
       
   363     
       
   364     link_url = self.makeLinkUrl()
       
   365     
       
   366     if (not sub_menu) and (not link_url) and (not self.force_in_menu):
       
   367       # no sub-menu, no valid link URL, and not forced to be in menu
       
   368       return None
       
   369     
       
   370     return menu.MenuItem(
       
   371       self.short_name, value=link_url, sub_menu=sub_menu)
       
   372 
       
   373   def makeDjangoUrl(self):
       
   374     """Returns the Django url() for the underlying self.url.
       
   375     """
       
   376     return self.url.makeDjangoUrl(page_name=self.short_name)
       
   377 
       
   378   def makeDjangoUrls(self):
       
   379     """Returns an ordered mapping of unique Django url() objects.
       
   380     
       
   381     Raises:
       
   382       KeyError if more than one Page has the same urlpattern.
       
   383       
       
   384     TODO(tlarsen): this really needs to be detected earlier via a
       
   385       global Page dictionary
       
   386     """
       
   387     return self._makeDjangoUrlsDict().values()
       
   388 
       
   389   def _makeDjangoUrlsDict(self):
       
   390     """Returns an ordered mapping of unique Django url() objects.
       
   391     
       
   392     Used to implement makeDjangoUrls().  See that method for details.
       
   393     """
       
   394     urlpatterns = NoOverwriteSortedDict()
       
   395 
       
   396     django_url = self.makeDjangoUrl()
       
   397     
       
   398     if django_url:
       
   399       urlpatterns[self.url.regex] = django_url
       
   400     
       
   401     for child in self.children:
       
   402       urlpatterns.update(child._makeDjangoUrlsDict())
       
   403     
       
   404     return urlpatterns
       
   405 
       
   406   _STR_FMT = '''%(indent)slong_name: %(long_name)s
       
   407 %(indent)sshort_name: %(short_name)s
       
   408 %(indent)sselected: %(selected)s
       
   409 %(indent)sannotation: %(annotation)s
       
   410 %(indent)surl: %(url)s
       
   411 '''
       
   412 
       
   413   def asIndentedStr(self, indent=''):
       
   414     """Returns an indented string representation useful for logging.
       
   415     
       
   416     Args:
       
   417       indent: an indentation string that is prepended to each line present
       
   418         in the multi-line string returned by this method.
       
   419     """
       
   420     strings = [ 
       
   421         self._STR_FMT % {'indent': indent, 'long_name': self.long_name,
       
   422                          'short_name': self.short_name,
       
   423                          'selected': self.selected,
       
   424                          'annotation': self.annotation,
       
   425                          'url': self.url.asIndentedStr(indent + ' ')}]
       
   426 
       
   427     for child in self.children:
       
   428       strings.extend(child.asIndentedStr(indent + '  '))
       
   429 
       
   430     return ''.join(strings)
       
   431 
       
   432   def __str__(self):
       
   433     """Returns a string representation useful for logging.
       
   434     """
       
   435     return self.asIndentedStr()
       
   436 
       
   437 
       
   438 class NonUrl(Url):
       
   439   """Placeholder for when a site-map entry is not a linkable URL.
       
   440   """
       
   441    
       
   442   def __init__(self, name):
       
   443     """Creates a non-linkable Url placeholder.
       
   444     
       
   445     Args:
       
   446       name: name of the non-view placeholder; see Url.__init__()
       
   447     """
       
   448     Url.__init__(self, None, None, name=name)
       
   449 
       
   450   def makeDjangoUrl(self, **extra_kwargs):
       
   451     """Always returns None, since NonUrl is never a Django view.
       
   452     """
       
   453     return None
       
   454 
       
   455 
       
   456 class NonPage(Page):
       
   457   """Placeholder for when a site-map entry is not a displayable page.
       
   458   """
       
   459 
       
   460   def __init__(self, non_url_name, long_name, **page_kwargs):
       
   461     """Constructs a NonUrl and passes it to base Page class __init__().
       
   462     
       
   463     Args:
       
   464       non_url_name:  unique (it *must* be) string that does not match
       
   465         the 'name' or 'view' of any other Url or NonUrl object;
       
   466         see Url.__init__() for details
       
   467       long_name:  see Page.__init__()
       
   468       **page_kwargs:  keyword arguments passed directly to the base
       
   469         Page class __init__()
       
   470     """
       
   471     non_url = NonUrl(non_url_name)
       
   472     Page.__init__(self, non_url, long_name, **page_kwargs)