Url and Page classes used to encapsulate the "site map" information (URL
authorTodd Larsen <tlarsen@google.com>
Sat, 04 Oct 2008 07:14:11 +0000
changeset 281 7caa8951cbc9
parent 280 ce9b10bbdd42
child 282 600e0a9bfa06
Url and Page classes used to encapsulate the "site map" information (URL regular expressions, Django views, menu item names, etc.) and the relation between pages in the site map. There are still lots of TODOs in this first pass. Patch by: Todd Larsen Review by: to-be-reviewed
app/soc/logic/site/page.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/app/soc/logic/site/page.py	Sat Oct 04 07:14:11 2008 +0000
@@ -0,0 +1,420 @@
+#!/usr/bin/python2.5
+#
+# Copyright 2008 the Melange authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Page properties, used to generate sidebar menus, urlpatterns, etc.
+"""
+
+__authors__ = [
+  '"Todd Larsen" <tlarsen@google.com>',
+  ]
+
+
+import copy
+import re
+
+from google.appengine.api import users
+
+from django.conf.urls import defaults
+
+from python25src import urllib
+
+from soc.logic import menu
+from soc.logic.no_overwrite_sorted_dict import NoOverwriteSortedDict
+
+
+class Url:
+  """The components of a Django URL pattern.
+  """
+   
+  def __init__(self, regex, view, kwargs=None, name=None, prefix=''):
+    """Collects Django urlpatterns info into a simple object.
+    
+    The arguments to this constructor correspond directly to the items in
+    the urlpatterns tuple, which also correspond to the parameters of
+    django.conf.urls.defaults.url().
+
+    Args:
+      regex: a Django URL regex pattern
+      view: a Django view, either a string or a callable
+      kwargs: optional dict of extra arguments passed to the view
+        function as keyword arguments, which is copy.deepcopy()'d;
+        default is None, which supplies an empty dict {}
+      name: optional name of the view
+      prefix: optional view prefix
+    """
+    self.regex = regex
+    self.view = view
+    
+    if kwargs:
+      self.kwargs = copy.deepcopy(kwargs)
+    else:
+      self.kwargs = {}
+
+    self.name = name
+    self.prefix = prefix
+
+  def makeDjangoUrl(self):
+    """Returns a Django url() used by urlpatterns.
+    """
+    return defaults.url(self.regex, self.view, kwargs=self.kwargs,
+                        name=self.name, prefix=self.prefix)
+
+  _STR_FMT = '''%(indent)sregex: %(regex)s
+%(indent)sview: %(view)s
+%(indent)skwargs: %(kwargs)s
+%(indent)sname: %(name)s
+%(indent)sprefix: %(prefix)s
+'''
+
+  def asIndentedStr(self, indent=''):
+    """Returns an indented string representation useful for logging.
+    
+    Args:
+      indent: an indentation string that is prepended to each line present
+        in the multi-line string returned by this method.
+    """
+    return self._STR_FMT % {'indent': indent, 'regex': self.regex,
+                            'view': self.view, 'kwargs': self.kwargs,
+                            'name': self.name, 'prefix': self.prefix}
+
+  def __str__(self):
+    """Returns a string representation useful for logging.
+    """
+    return self.asIndentedStr()
+
+
+class Page:
+  """An abstraction that combines a Django view with sidebar menu info.
+  """
+  
+  def __init__(self, url, long_name, short_name=None, selected=False,
+               annotation=None, parent=None, link_url=None,
+               in_breadcrumb=True, force_in_menu=False):
+    """Initializes the menu item attributes from supplied arguments.
+    
+    Args:
+      url: a Url object
+      long_name: title of the Page
+      short_name: optional menu item and breadcrumb name; default is
+        None, in which case long_name is used
+      selected: Boolean indicating if this menu item is selected;
+        default is False
+      annotation: optional annotation associated with the menu item;
+        default is None
+      parent: optional Page that is the logical "parent" of this page;
+        used to construct hierarchical menus and breadcrumb trails
+      link_url: optional alternate URL link; if supplied, it is returned
+        by makeLinkUrl(); default is None, and makeLinkUrl() attempts to
+        create a URL link from url.regex 
+      in_breadcrumb: if True, the Page appears in breadcrumb trails;
+        default is True
+      force_in_menu: if True, the Page appears in menus even if it does
+        not have a usable link_url; default is False, which excludes
+        the Page if makeLinkUrl() returns None
+    """
+    self.url = url
+    self.long_name = long_name
+    self.annotation = annotation
+    self.in_breadcrumb = in_breadcrumb
+    self.force_in_menu = force_in_menu
+    self.link_url = link_url
+    self.selected = selected
+    self.parent = parent
+
+    # create ordered, unique mappings of URLs and view names to Pages
+    self.child_by_urls = NoOverwriteSortedDict()
+    self.child_by_views = NoOverwriteSortedDict()
+    
+    if not short_name:
+      short_name = long_name
+      
+    self.short_name = short_name
+    
+    if parent:
+      # tell parent Page about parent <- child relationship
+      parent.addChild(self)
+      
+    # TODO(tlarsen): build some sort of global Page dictionary to detect
+    #   collisions sooner and to make global queries from URLs to pages
+    #   and views to Pages possible (without requiring a recursive search)
+
+  def getChildren(self):
+    """Returns an iterator over any child Pages 
+    """
+    for page, _ in self.child_by_urls.itervalues():
+      yield page
+
+  children = property(getChildren)
+
+  def getChild(self, url=None, regex=None, view=None,
+               name=None, prefix=None, request_path=None):
+    """Returns a child Page if one can be identified; or None otherwise.
+    
+    All of the parameters to this method are optional, but at least one
+    must be supplied to return anything other than None.  The parameters
+    are tried in the order they are listed in the "Args:" section, and
+    this method exits on the first "match".
+    
+    Args:
+      url: a Url object, used to overwrite the regex, view, name, and
+        prefix parameters if present; default is None
+      regex: a regex pattern string, used to return the associated
+        child Page
+      view: a view string, used to return the associated child Page
+      name: a name string, used to return the associated child Page
+      prefix: (currently unused, see TODO below in code)
+      request_path: optional HTTP request path string (request.path)
+        with no query arguments
+    """
+    # this code is yucky; there is probably a better way...
+    if url:
+      regex = url.regex
+      view = url.view
+      name = url.name
+      prefix = url.prefix
+
+    if regex in self.child_by_urls:
+      # regex supplied and Page found, so return that Page
+      return self.child_by_urls[regex][0]
+  
+    # TODO(tlarsen): make this work correctly with prefixes
+
+    if view in self.child_views:
+      # view supplied and Page found, so return that Page
+      return self.child_by_views[view]
+  
+    if name in self.child_views:
+      # name supplied and Page found, so return that Page
+      return self.child_by_views[name]
+
+    if request_path.startswith('/'):
+      request_path = request_path[1:]
+
+    # attempt to match the HTTP request path with a Django URL pattern
+    for pattern, (page, regex) in self.child_by_urls:
+      if regex.match(request_path):
+        return page
+
+    return None
+
+  def addChild(self, page):
+    """Adds a unique Page as a child Page of this parent.
+    
+    Raises:
+      ValueError if page.url.regex is not a string.
+      ValueError if page.url.view is not a string.
+      ValueError if page.url.name is supplied but is not a string.
+      KeyError if page.url.regex is already associated with another Page.
+      KeyError if page.url.view/name is already associated with another Page.
+    """
+    # TODO(tlarsen): see also TODO in __init__() about global Page dictionary
+
+    url = page.url
+    
+    if not isinstance(url.regex, basestring):
+      raise ValueError('"regex" must be a string, not a compiled regex')
+
+    # TODO(tlarsen): see if Django has some way exposed in its API to get
+    #   the view name from the request path matched against urlpatterns;
+    #   if so, there would be no need for child_by_urls, because the
+    #   request path could be converted for us by Django into a view/name,
+    #   and we could just use child_by_views with that string instead
+    self.child_by_urls[url.regex] = (page, re.compile(url.regex))
+
+    # TODO(tlarsen): make this work correctly if url has a prefix
+    #   (not sure how to make this work with include() views...)
+    if url.name:
+      if not isinstance(url.name, basestring):
+        raise ValueError('"name" must be a string if it is supplied')
+
+      view = url.name
+    elif isinstance(url.view, basestring):
+      view = url.view
+    else:
+      raise ValueError('"view" must be a string if "name" is not supplied')
+
+    self.child_by_views[view] = page
+
+  def delChild(self, url=None, regex=None, view=None, name=None,
+               prefix=None):
+    """Removes a child Page if one can be identified.
+    
+    All of the parameters to this method are optional, but at least one
+    must be supplied in order to remove a child Page.  The parameters
+    are tried in the order they are listed in the "Args:" section, and
+    this method uses the first "match".
+    
+    Args:
+      url: a Url object, used to overwrite the regex, view, name, and
+        prefix parameters if present; default is None
+      regex: a regex pattern string, used to remove the associated
+        child Page
+      view: a view string, used to remove the associated child Page
+      name: a name string, used to remove the associated child Page
+      prefix: (currently unused, see TODO below in code)
+      
+    Raises:
+      KeyError if the child Page could not be definitively identified in
+      order to delete it.
+    """
+    # this code is yucky; there is probably a better way...
+    if url:
+      regex = url.regex
+      view = url.view
+      name = url.name
+      prefix = url.prefix
+
+    # try to find page by regex, view, or name, in turn
+    if regex in self.child_by_urls:
+      url = self.child_by_urls[regex][0].url
+      view = url.view
+      name = url.name
+      prefix = url.prefix
+    elif view in self.child_views:
+      # TODO(tlarsen): make this work correctly with prefixes
+      regex = self.child_by_views[view].url.regex
+    elif name in self.child_views:
+      regex = self.child_by_views[name].url.regex
+
+    # regex must refer to an existing Page at this point
+    del self.child_urls[regex]
+
+    if not isinstance(view, basestring):
+      # use name if view is callable() or None, etc.
+      view = name
+
+    # TODO(tlarsen): make this work correctly with prefixes
+    del self.child_by_views[view]
+    
+
+  def makeLinkUrl(self):
+    """Makes a URL link suitable for <A HREF> use.
+    
+    Returns:
+      self.link_url if link_url was supplied to the __init__() constructor
+        and it is a non-False value
+       -OR-
+      a suitable URL extracted from the url.regex, if possible
+       -OR-
+      None if url.regex contains quotable characters that have not already
+        been quoted (that is, % is left untouched, so quote suspect
+        characters in url.regex that would otherwise be quoted)
+    """
+    if self.link_url:
+      return self.link_url
+
+    link = self.url.regex
+    
+    if link.startswith('^'):
+      link = link[1:]
+    
+    if link.endswith('$'):
+      link = link[:-1]
+
+    if not link.startswith('/'):
+      link = '/' + link
+    
+    # path separators and already-quoted characters are OK
+    if link != urllib.quote(link, safe='/%'):
+      return None
+
+    return link
+
+  def makeMenuItem(self):
+    """Returns a menu.MenuItem for the Page (and any child Pages).
+    """
+    child_items = []
+    
+    for child in self.children:
+      child_item = child.makeMenuItem()
+      if child_item:
+        child_items.append(child_item)
+    
+    if child_items:
+      sub_menu = menu.Menu(items=child_items)
+    else:
+      sub_menu = None
+    
+    link_url = self.makeLinkUrl()
+    
+    if (not sub_menu) and (not link_url) and (not self.force_in_menu):
+      # no sub-menu, no valid link URL, and not forced to be in menu
+      return None
+    
+    return menu.MenuItem(
+      self.short_name, value=link_url, sub_menu=sub_menu)
+
+  def makeDjangoUrl(self):
+    """Returns the Django url() for the underlying self.url.
+    """
+    return self.url.makeDjangoUrl()
+
+  def makeDjangoUrls(self):
+    """Returns an ordered mapping of unique Django url() objects.
+    
+    Raises:
+      KeyError if more than one Page has the same urlpattern.
+      
+    TODO(tlarsen): this really needs to be detected earlier via a
+      global Page dictionary
+    """
+    return self._makeDjangoUrlsDict().values()
+
+  def _makeDjangoUrlsDict(self):
+    """Returns an ordered mapping of unique Django url() objects.
+    
+    Used to implement makeDjangoUrls().  See that method for details.
+    """
+    urlpatterns = NoOverwriteSortedDict()
+    
+    if self.url.view:
+      urlpatterns[self.url.regex] = self.makeDjangoUrl()
+    
+    for child in self.children:
+      urlpatterns.update(child._makeDjangoUrlsDict())
+    
+    return urlpatterns
+
+  _STR_FMT = '''%(indent)slong_name: %(long_name)s
+%(indent)sshort_name: %(short_name)s
+%(indent)sselected: %(selected)s
+%(indent)sannotation: %(annotation)s
+%(indent)surl: %(url)s
+'''
+
+  def asIndentedStr(self, indent=''):
+    """Returns an indented string representation useful for logging.
+    
+    Args:
+      indent: an indentation string that is prepended to each line present
+        in the multi-line string returned by this method.
+    """
+    strings = [ 
+        self._STR_FMT % {'indent': indent, 'long_name': self.long_name,
+                         'short_name': self.short_name,
+                         'selected': self.selected,
+                         'annotation': self.annotation,
+                         'url': self.url.asIndentedStr(indent + ' ')}]
+
+    for child in self.children:
+      strings.extend(child.asIndentedStr(indent + '  '))
+
+    return ''.join(strings)
+
+  def __str__(self):
+    """Returns a string representation useful for logging.
+    """
+    return self.asIndentedStr()