app/soc/logic/site/page.py
changeset 281 7caa8951cbc9
child 372 8595c1129c74
--- /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()