|
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 google.appengine.api import users |
|
29 |
|
30 from django.conf.urls import defaults |
|
31 |
|
32 from python25src import urllib |
|
33 |
|
34 from soc.logic import menu |
|
35 from soc.logic.no_overwrite_sorted_dict import NoOverwriteSortedDict |
|
36 |
|
37 |
|
38 class Url: |
|
39 """The components of a Django URL pattern. |
|
40 """ |
|
41 |
|
42 def __init__(self, regex, view, kwargs=None, name=None, prefix=''): |
|
43 """Collects Django urlpatterns info into a simple object. |
|
44 |
|
45 The arguments to this constructor correspond directly to the items in |
|
46 the urlpatterns tuple, which also correspond to the parameters of |
|
47 django.conf.urls.defaults.url(). |
|
48 |
|
49 Args: |
|
50 regex: a Django URL regex pattern |
|
51 view: a Django view, either a string or a callable |
|
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 |
|
56 prefix: optional view prefix |
|
57 """ |
|
58 self.regex = regex |
|
59 self.view = view |
|
60 |
|
61 if kwargs: |
|
62 self.kwargs = copy.deepcopy(kwargs) |
|
63 else: |
|
64 self.kwargs = {} |
|
65 |
|
66 self.name = name |
|
67 self.prefix = prefix |
|
68 |
|
69 def makeDjangoUrl(self): |
|
70 """Returns a Django url() used by urlpatterns. |
|
71 """ |
|
72 return defaults.url(self.regex, self.view, kwargs=self.kwargs, |
|
73 name=self.name, prefix=self.prefix) |
|
74 |
|
75 _STR_FMT = '''%(indent)sregex: %(regex)s |
|
76 %(indent)sview: %(view)s |
|
77 %(indent)skwargs: %(kwargs)s |
|
78 %(indent)sname: %(name)s |
|
79 %(indent)sprefix: %(prefix)s |
|
80 ''' |
|
81 |
|
82 def asIndentedStr(self, indent=''): |
|
83 """Returns an indented string representation useful for logging. |
|
84 |
|
85 Args: |
|
86 indent: an indentation string that is prepended to each line present |
|
87 in the multi-line string returned by this method. |
|
88 """ |
|
89 return self._STR_FMT % {'indent': indent, 'regex': self.regex, |
|
90 'view': self.view, 'kwargs': self.kwargs, |
|
91 'name': self.name, 'prefix': self.prefix} |
|
92 |
|
93 def __str__(self): |
|
94 """Returns a string representation useful for logging. |
|
95 """ |
|
96 return self.asIndentedStr() |
|
97 |
|
98 |
|
99 class Page: |
|
100 """An abstraction that combines a Django view with sidebar menu info. |
|
101 """ |
|
102 |
|
103 def __init__(self, url, long_name, short_name=None, selected=False, |
|
104 annotation=None, parent=None, link_url=None, |
|
105 in_breadcrumb=True, force_in_menu=False): |
|
106 """Initializes the menu item attributes from supplied arguments. |
|
107 |
|
108 Args: |
|
109 url: a Url object |
|
110 long_name: title of the Page |
|
111 short_name: optional menu item and breadcrumb name; default is |
|
112 None, in which case long_name is used |
|
113 selected: Boolean indicating if this menu item is selected; |
|
114 default is False |
|
115 annotation: optional annotation associated with the menu item; |
|
116 default is None |
|
117 parent: optional Page that is the logical "parent" of this page; |
|
118 used to construct hierarchical menus and breadcrumb trails |
|
119 link_url: optional alternate URL link; if supplied, it is returned |
|
120 by makeLinkUrl(); default is None, and makeLinkUrl() attempts to |
|
121 create a URL link from url.regex |
|
122 in_breadcrumb: if True, the Page appears in breadcrumb trails; |
|
123 default is True |
|
124 force_in_menu: if True, the Page appears in menus even if it does |
|
125 not have a usable link_url; default is False, which excludes |
|
126 the Page if makeLinkUrl() returns None |
|
127 """ |
|
128 self.url = url |
|
129 self.long_name = long_name |
|
130 self.annotation = annotation |
|
131 self.in_breadcrumb = in_breadcrumb |
|
132 self.force_in_menu = force_in_menu |
|
133 self.link_url = link_url |
|
134 self.selected = selected |
|
135 self.parent = parent |
|
136 |
|
137 # create ordered, unique mappings of URLs and view names to Pages |
|
138 self.child_by_urls = NoOverwriteSortedDict() |
|
139 self.child_by_views = NoOverwriteSortedDict() |
|
140 |
|
141 if not short_name: |
|
142 short_name = long_name |
|
143 |
|
144 self.short_name = short_name |
|
145 |
|
146 if parent: |
|
147 # tell parent Page about parent <- child relationship |
|
148 parent.addChild(self) |
|
149 |
|
150 # TODO(tlarsen): build some sort of global Page dictionary to detect |
|
151 # collisions sooner and to make global queries from URLs to pages |
|
152 # and views to Pages possible (without requiring a recursive search) |
|
153 |
|
154 def getChildren(self): |
|
155 """Returns an iterator over any child Pages |
|
156 """ |
|
157 for page, _ in self.child_by_urls.itervalues(): |
|
158 yield page |
|
159 |
|
160 children = property(getChildren) |
|
161 |
|
162 def getChild(self, url=None, regex=None, view=None, |
|
163 name=None, prefix=None, request_path=None): |
|
164 """Returns a child Page if one can be identified; or None otherwise. |
|
165 |
|
166 All of the parameters to this method are optional, but at least one |
|
167 must be supplied to return anything other than None. The parameters |
|
168 are tried in the order they are listed in the "Args:" section, and |
|
169 this method exits on the first "match". |
|
170 |
|
171 Args: |
|
172 url: a Url object, used to overwrite the regex, view, name, and |
|
173 prefix parameters if present; default is None |
|
174 regex: a regex pattern string, used to return the associated |
|
175 child Page |
|
176 view: a view string, used to return the associated child Page |
|
177 name: a name string, used to return the associated child Page |
|
178 prefix: (currently unused, see TODO below in code) |
|
179 request_path: optional HTTP request path string (request.path) |
|
180 with no query arguments |
|
181 """ |
|
182 # this code is yucky; there is probably a better way... |
|
183 if url: |
|
184 regex = url.regex |
|
185 view = url.view |
|
186 name = url.name |
|
187 prefix = url.prefix |
|
188 |
|
189 if regex in self.child_by_urls: |
|
190 # regex supplied and Page found, so return that Page |
|
191 return self.child_by_urls[regex][0] |
|
192 |
|
193 # TODO(tlarsen): make this work correctly with prefixes |
|
194 |
|
195 if view in self.child_views: |
|
196 # view supplied and Page found, so return that Page |
|
197 return self.child_by_views[view] |
|
198 |
|
199 if name in self.child_views: |
|
200 # name supplied and Page found, so return that Page |
|
201 return self.child_by_views[name] |
|
202 |
|
203 if request_path.startswith('/'): |
|
204 request_path = request_path[1:] |
|
205 |
|
206 # attempt to match the HTTP request path with a Django URL pattern |
|
207 for pattern, (page, regex) in self.child_by_urls: |
|
208 if regex.match(request_path): |
|
209 return page |
|
210 |
|
211 return None |
|
212 |
|
213 def addChild(self, page): |
|
214 """Adds a unique Page as a child Page of this parent. |
|
215 |
|
216 Raises: |
|
217 ValueError if page.url.regex is not a string. |
|
218 ValueError if page.url.view is not a string. |
|
219 ValueError if page.url.name is supplied but is not a string. |
|
220 KeyError if page.url.regex is already associated with another Page. |
|
221 KeyError if page.url.view/name is already associated with another Page. |
|
222 """ |
|
223 # TODO(tlarsen): see also TODO in __init__() about global Page dictionary |
|
224 |
|
225 url = page.url |
|
226 |
|
227 if not isinstance(url.regex, basestring): |
|
228 raise ValueError('"regex" must be a string, not a compiled regex') |
|
229 |
|
230 # TODO(tlarsen): see if Django has some way exposed in its API to get |
|
231 # the view name from the request path matched against urlpatterns; |
|
232 # if so, there would be no need for child_by_urls, because the |
|
233 # request path could be converted for us by Django into a view/name, |
|
234 # and we could just use child_by_views with that string instead |
|
235 self.child_by_urls[url.regex] = (page, re.compile(url.regex)) |
|
236 |
|
237 # TODO(tlarsen): make this work correctly if url has a prefix |
|
238 # (not sure how to make this work with include() views...) |
|
239 if url.name: |
|
240 if not isinstance(url.name, basestring): |
|
241 raise ValueError('"name" must be a string if it is supplied') |
|
242 |
|
243 view = url.name |
|
244 elif isinstance(url.view, basestring): |
|
245 view = url.view |
|
246 else: |
|
247 raise ValueError('"view" must be a string if "name" is not supplied') |
|
248 |
|
249 self.child_by_views[view] = page |
|
250 |
|
251 def delChild(self, url=None, regex=None, view=None, name=None, |
|
252 prefix=None): |
|
253 """Removes a child Page if one can be identified. |
|
254 |
|
255 All of the parameters to this method are optional, but at least one |
|
256 must be supplied in order to remove a child Page. The parameters |
|
257 are tried in the order they are listed in the "Args:" section, and |
|
258 this method uses the first "match". |
|
259 |
|
260 Args: |
|
261 url: a Url object, used to overwrite the regex, view, name, and |
|
262 prefix parameters if present; default is None |
|
263 regex: a regex pattern string, used to remove the associated |
|
264 child Page |
|
265 view: a view string, used to remove the associated child Page |
|
266 name: a name string, used to remove the associated child Page |
|
267 prefix: (currently unused, see TODO below in code) |
|
268 |
|
269 Raises: |
|
270 KeyError if the child Page could not be definitively identified in |
|
271 order to delete it. |
|
272 """ |
|
273 # this code is yucky; there is probably a better way... |
|
274 if url: |
|
275 regex = url.regex |
|
276 view = url.view |
|
277 name = url.name |
|
278 prefix = url.prefix |
|
279 |
|
280 # try to find page by regex, view, or name, in turn |
|
281 if regex in self.child_by_urls: |
|
282 url = self.child_by_urls[regex][0].url |
|
283 view = url.view |
|
284 name = url.name |
|
285 prefix = url.prefix |
|
286 elif view in self.child_views: |
|
287 # TODO(tlarsen): make this work correctly with prefixes |
|
288 regex = self.child_by_views[view].url.regex |
|
289 elif name in self.child_views: |
|
290 regex = self.child_by_views[name].url.regex |
|
291 |
|
292 # regex must refer to an existing Page at this point |
|
293 del self.child_urls[regex] |
|
294 |
|
295 if not isinstance(view, basestring): |
|
296 # use name if view is callable() or None, etc. |
|
297 view = name |
|
298 |
|
299 # TODO(tlarsen): make this work correctly with prefixes |
|
300 del self.child_by_views[view] |
|
301 |
|
302 |
|
303 def makeLinkUrl(self): |
|
304 """Makes a URL link suitable for <A HREF> use. |
|
305 |
|
306 Returns: |
|
307 self.link_url if link_url was supplied to the __init__() constructor |
|
308 and it is a non-False value |
|
309 -OR- |
|
310 a suitable URL extracted from the url.regex, if possible |
|
311 -OR- |
|
312 None if url.regex contains quotable characters that have not already |
|
313 been quoted (that is, % is left untouched, so quote suspect |
|
314 characters in url.regex that would otherwise be quoted) |
|
315 """ |
|
316 if self.link_url: |
|
317 return self.link_url |
|
318 |
|
319 link = self.url.regex |
|
320 |
|
321 if link.startswith('^'): |
|
322 link = link[1:] |
|
323 |
|
324 if link.endswith('$'): |
|
325 link = link[:-1] |
|
326 |
|
327 if not link.startswith('/'): |
|
328 link = '/' + link |
|
329 |
|
330 # path separators and already-quoted characters are OK |
|
331 if link != urllib.quote(link, safe='/%'): |
|
332 return None |
|
333 |
|
334 return link |
|
335 |
|
336 def makeMenuItem(self): |
|
337 """Returns a menu.MenuItem for the Page (and any child Pages). |
|
338 """ |
|
339 child_items = [] |
|
340 |
|
341 for child in self.children: |
|
342 child_item = child.makeMenuItem() |
|
343 if child_item: |
|
344 child_items.append(child_item) |
|
345 |
|
346 if child_items: |
|
347 sub_menu = menu.Menu(items=child_items) |
|
348 else: |
|
349 sub_menu = None |
|
350 |
|
351 link_url = self.makeLinkUrl() |
|
352 |
|
353 if (not sub_menu) and (not link_url) and (not self.force_in_menu): |
|
354 # no sub-menu, no valid link URL, and not forced to be in menu |
|
355 return None |
|
356 |
|
357 return menu.MenuItem( |
|
358 self.short_name, value=link_url, sub_menu=sub_menu) |
|
359 |
|
360 def makeDjangoUrl(self): |
|
361 """Returns the Django url() for the underlying self.url. |
|
362 """ |
|
363 return self.url.makeDjangoUrl() |
|
364 |
|
365 def makeDjangoUrls(self): |
|
366 """Returns an ordered mapping of unique Django url() objects. |
|
367 |
|
368 Raises: |
|
369 KeyError if more than one Page has the same urlpattern. |
|
370 |
|
371 TODO(tlarsen): this really needs to be detected earlier via a |
|
372 global Page dictionary |
|
373 """ |
|
374 return self._makeDjangoUrlsDict().values() |
|
375 |
|
376 def _makeDjangoUrlsDict(self): |
|
377 """Returns an ordered mapping of unique Django url() objects. |
|
378 |
|
379 Used to implement makeDjangoUrls(). See that method for details. |
|
380 """ |
|
381 urlpatterns = NoOverwriteSortedDict() |
|
382 |
|
383 if self.url.view: |
|
384 urlpatterns[self.url.regex] = self.makeDjangoUrl() |
|
385 |
|
386 for child in self.children: |
|
387 urlpatterns.update(child._makeDjangoUrlsDict()) |
|
388 |
|
389 return urlpatterns |
|
390 |
|
391 _STR_FMT = '''%(indent)slong_name: %(long_name)s |
|
392 %(indent)sshort_name: %(short_name)s |
|
393 %(indent)sselected: %(selected)s |
|
394 %(indent)sannotation: %(annotation)s |
|
395 %(indent)surl: %(url)s |
|
396 ''' |
|
397 |
|
398 def asIndentedStr(self, indent=''): |
|
399 """Returns an indented string representation useful for logging. |
|
400 |
|
401 Args: |
|
402 indent: an indentation string that is prepended to each line present |
|
403 in the multi-line string returned by this method. |
|
404 """ |
|
405 strings = [ |
|
406 self._STR_FMT % {'indent': indent, 'long_name': self.long_name, |
|
407 'short_name': self.short_name, |
|
408 'selected': self.selected, |
|
409 'annotation': self.annotation, |
|
410 'url': self.url.asIndentedStr(indent + ' ')}] |
|
411 |
|
412 for child in self.children: |
|
413 strings.extend(child.asIndentedStr(indent + ' ')) |
|
414 |
|
415 return ''.join(strings) |
|
416 |
|
417 def __str__(self): |
|
418 """Returns a string representation useful for logging. |
|
419 """ |
|
420 return self.asIndentedStr() |