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) |
|