Merge lp:~openerp-dev/openerp-web/7.0-controller-extension-xmo into lp:openerp-web/7.0

Proposed by Antony Lesuisse (OpenERP)
Status: Merged
Merged at revision: 3904
Proposed branch: lp:~openerp-dev/openerp-web/7.0-controller-extension-xmo
Merge into: lp:openerp-web/7.0
Diff against target: 584 lines (+446/-23)
5 files modified
addons/web/controllers/main.py (+15/-9)
addons/web/http.py (+51/-9)
addons/web/tests/__init__.py (+2/-1)
addons/web/tests/test_dispatch.py (+373/-0)
addons/web_diagram/controllers/main.py (+5/-4)
To merge this branch: bzr merge lp:~openerp-dev/openerp-web/7.0-controller-extension-xmo
Reviewer Review Type Date Requested Status
OpenERP Core Team Pending
Review via email: mp+157151@code.launchpad.net
To post a comment you must log in.
3880. By Xavier (Open ERP)

[FIX] Extension of controller in-place with explicit spec of same _cp_path

When extending a controller in-place (e.g. A(Controller), B(A)) and
providing the exact same _cp_path as parent (no-op) execution path
would go into handler for _cp_path overwriting and raise an assertion
error for overwriting of existing controller.

Except this is allowed (if ugly) pattern, so warn & ignore behavior
(it is harmless).

3881. By Xavier (Open ERP)

[IMP] only use name of class being created: module is incorrect for dynamically created classes

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'addons/web/controllers/main.py'
2--- addons/web/controllers/main.py 2013-04-05 13:47:47 +0000
3+++ addons/web/controllers/main.py 2013-04-18 09:47:37 +0000
4@@ -1,11 +1,9 @@
5 # -*- coding: utf-8 -*-
6-
7 import ast
8 import base64
9 import csv
10 import glob
11 import itertools
12-import logging
13 import operator
14 import datetime
15 import hashlib
16@@ -30,7 +28,6 @@
17 xlwt = None
18
19 import openerp
20-import openerp.modules.registry
21 from openerp.tools.translate import _
22
23 from .. import http
24@@ -1439,7 +1436,8 @@
25 else:
26 return False
27
28-class Export(View):
29+
30+class Export(openerpweb.Controller):
31 _cp_path = "/web/export"
32
33 @openerpweb.jsonrequest
34@@ -1580,7 +1578,13 @@
35 (prefix + '/' + k, prefix_string + '/' + v)
36 for k, v in self.fields_info(req, model, export_fields).iteritems())
37
38- #noinspection PyPropertyDefinition
39+
40+class ExportFormat(object):
41+ """
42+ Superclass for export formats, should probably be an abc and have a way to
43+ generate _cp_path from fmt but it's a pain to deal with conflicts with
44+ ControllerType
45+ """
46 @property
47 def content_type(self):
48 """ Provides the format's content type """
49@@ -1621,14 +1625,14 @@
50 else:
51 columns_headers = [val['label'].strip() for val in fields]
52
53-
54 return req.make_response(self.from_data(columns_headers, import_data),
55 headers=[('Content-Disposition',
56 content_disposition(self.filename(model), req)),
57 ('Content-Type', self.content_type)],
58 cookies={'fileToken': int(token)})
59
60-class CSVExport(Export):
61+
62+class CSVExport(ExportFormat, http.Controller):
63 _cp_path = '/web/export/csv'
64 fmt = {'tag': 'csv', 'label': 'CSV'}
65
66@@ -1663,7 +1667,8 @@
67 fp.close()
68 return data
69
70-class ExcelExport(Export):
71+
72+class ExcelExport(ExportFormat, http.Controller):
73 _cp_path = '/web/export/xls'
74 fmt = {
75 'tag': 'xls',
76@@ -1702,7 +1707,8 @@
77 fp.close()
78 return data
79
80-class Reports(View):
81+
82+class Reports(openerpweb.Controller):
83 _cp_path = "/web/report"
84 POLLING_DELAY = 0.25
85 TYPES_MAPPING = {
86
87=== modified file 'addons/web/http.py'
88--- addons/web/http.py 2013-03-01 17:16:16 +0000
89+++ addons/web/http.py 2013-04-18 09:47:37 +0000
90@@ -354,18 +354,64 @@
91 #----------------------------------------------------------
92 addons_module = {}
93 addons_manifest = {}
94-controllers_class = []
95-controllers_object = {}
96+controllers_class = {}
97 controllers_path = {}
98
99 class ControllerType(type):
100 def __init__(cls, name, bases, attrs):
101 super(ControllerType, cls).__init__(name, bases, attrs)
102- controllers_class.append(("%s.%s" % (cls.__module__, cls.__name__), cls))
103+ # Only for root "Controller"
104+ if bases == (object,):
105+ assert name == 'Controller'
106+ return
107+
108+ path = attrs.get('_cp_path')
109+ if Controller in bases:
110+ assert path, "Controller subclass %s missing a _cp_path" % name
111+ else:
112+ parent_paths = set(base._cp_path for base in bases
113+ if issubclass(base, Controller))
114+ assert len(parent_paths) == 1,\
115+ "%s inheriting from multiple controllers is not supported" % (
116+ name)
117+ [parent_path] = parent_paths
118+ [parent] = [
119+ controller for controller in controllers_class.itervalues()
120+ if controller._cp_path == parent_path]
121+
122+ # inherit from a Controller subclass
123+ if path:
124+ # if extending in place with same URL, ignore URL
125+ if parent_path == path:
126+ _logger.warn(
127+ "Controller %s extending %s in-place should not "
128+ "explicitly specify URL", name, parent)
129+ return
130+ _logger.warn("Re-exposing %s at %s.\n"
131+ "\tThis usage is unsupported.",
132+ parent.__name__,
133+ attrs['_cp_path'])
134+
135+ if path:
136+ assert path not in controllers_class,\
137+ "Trying to expose %s at the same URL as %s" % (
138+ name, controllers_class[path])
139+ controllers_class[path] = cls
140+
141
142 class Controller(object):
143 __metaclass__ = ControllerType
144
145+ def __new__(cls, *args, **kwargs):
146+ subclasses = [c for c in cls.__subclasses__()
147+ if c._cp_path == cls._cp_path]
148+ if subclasses:
149+ name = "%s (+%s)" % (
150+ cls.__name__,
151+ '+'.join(sub.__name__ for sub in subclasses))
152+ cls = type(name, tuple(reversed(subclasses)), {})
153+ return object.__new__(cls)
154+
155 #----------------------------------------------------------
156 # Session context manager
157 #----------------------------------------------------------
158@@ -558,12 +604,8 @@
159 addons_manifest[module] = manifest
160 self.statics['/%s/static' % module] = path_static
161
162- for k, v in controllers_class:
163- if k not in controllers_object:
164- o = v()
165- controllers_object[k] = o
166- if hasattr(o, '_cp_path'):
167- controllers_path[o._cp_path] = o
168+ for c in controllers_class.itervalues():
169+ controllers_path[c._cp_path] = c()
170
171 app = werkzeug.wsgi.SharedDataMiddleware(self.dispatch, self.statics)
172 self.dispatch = DisableCacheMiddleware(app)
173
174=== modified file 'addons/web/tests/__init__.py'
175--- addons/web/tests/__init__.py 2012-11-26 14:05:25 +0000
176+++ addons/web/tests/__init__.py 2013-04-18 09:47:37 +0000
177@@ -1,9 +1,10 @@
178 # -*- coding: utf-8 -*-
179-from . import test_dataset, test_menu, test_serving_base, test_js
180+from . import test_dataset, test_menu, test_serving_base, test_js, test_dispatch
181
182 fast_suite = []
183 checks = [
184 test_dataset,
185 test_menu,
186 test_serving_base,
187+ test_dispatch,
188 ]
189
190=== added file 'addons/web/tests/test_dispatch.py'
191--- addons/web/tests/test_dispatch.py 1970-01-01 00:00:00 +0000
192+++ addons/web/tests/test_dispatch.py 2013-04-18 09:47:37 +0000
193@@ -0,0 +1,373 @@
194+# -*- coding: utf-8 -*-
195+import contextlib
196+import json
197+import logging
198+import logging.handlers
199+import types
200+import unittest2
201+
202+from .. import http
203+import werkzeug.test
204+
205+
206+def setUpModule():
207+ """
208+ Force load_addons once to import all the crap we don't care for as this
209+ thing is full of side-effects
210+ """
211+ http.Root().load_addons()
212+
213+class DispatchCleanup(unittest2.TestCase):
214+ """
215+ Cleans up controllers registries in the web client so it's possible to
216+ test controllers registration and dispatching in isolation.
217+ """
218+ def setUp(self):
219+ self.classes = http.controllers_class
220+ self.paths = http.controllers_path
221+
222+ http.controllers_class = {}
223+ http.controllers_path = {}
224+
225+ def tearDown(self):
226+ http.controllers_path = self.paths
227+ http.controllers_class = self.classes
228+
229+
230+def jsonrpc_body(params=None):
231+ """
232+ Builds and dumps the body of a JSONRPC request with params ``params``
233+ """
234+ return json.dumps({
235+ 'jsonrpc': '2.0',
236+ 'method': 'call',
237+ 'id': None,
238+ 'params': params or {},
239+ })
240+
241+
242+def jsonrpc_response(result=None):
243+ """
244+ Builds a JSONRPC response (as a Python dict) with result ``result``
245+ """
246+ return {
247+ u'jsonrpc': u'2.0',
248+ u'id': None,
249+ u'result': result,
250+ }
251+
252+
253+class TestHandler(logging.handlers.BufferingHandler):
254+ def __init__(self):
255+ logging.handlers.BufferingHandler.__init__(self, 0)
256+
257+ def shouldFlush(self, record):
258+ return False
259+
260+@contextlib.contextmanager
261+def capture_logging(logger, level=logging.DEBUG):
262+ logger = logging.getLogger(logger)
263+ old_level = logger.level
264+ old_handlers = logger.handlers
265+ old_propagate = logger.propagate
266+
267+ test_handler = TestHandler()
268+ logger.handlers = [test_handler]
269+ logger.setLevel(level)
270+ logger.propagate = False
271+
272+ try:
273+ yield test_handler
274+ finally:
275+ logger.propagate = old_propagate
276+ logger.setLevel(old_level)
277+ logger.handlers = old_handlers
278+
279+
280+class TestDispatching(DispatchCleanup):
281+ def setUp(self):
282+ super(TestDispatching, self).setUp()
283+ self.app = http.Root()
284+ self.client = werkzeug.test.Client(self.app)
285+
286+ def test_not_exposed(self):
287+ class CatController(http.Controller):
288+ _cp_path = '/cat'
289+
290+ def index(self):
291+ return 'Blessid iz da feline'
292+
293+ self.app.load_addons()
294+
295+ body, status, headers = self.client.get('/cat')
296+ self.assertEqual('404 NOT FOUND', status)
297+
298+ def test_basic_http(self):
299+ class CatController(http.Controller):
300+ _cp_path = '/cat'
301+
302+ @http.httprequest
303+ def index(self, req):
304+ return 'no walk in counsil of wickid,'
305+
306+ self.app.load_addons()
307+
308+ body, status, headers = self.client.get('/cat')
309+ self.assertEqual('200 OK', status)
310+ self.assertEqual('no walk in counsil of wickid,', ''.join(body))
311+
312+ def test_basic_jsonrpc(self):
313+ class CatController(http.Controller):
314+ _cp_path = '/cat'
315+
316+ @http.jsonrequest
317+ def index(self, req):
318+ return 'no place paws in path of da sinnerz,'
319+ self.app.load_addons()
320+
321+ body, status, headers = self.client.post('/cat', data=jsonrpc_body())
322+
323+ self.assertEqual('200 OK', status)
324+ self.assertEqual(
325+ jsonrpc_response('no place paws in path of da sinnerz,'),
326+ json.loads(''.join(body)))
327+
328+
329+class TestSubclassing(DispatchCleanup):
330+ def setUp(self):
331+ super(TestSubclassing, self).setUp()
332+ self.app = http.Root()
333+ self.client = werkzeug.test.Client(self.app)
334+
335+ def test_add_method(self):
336+ class CatController(http.Controller):
337+ _cp_path = '/cat'
338+
339+ @http.httprequest
340+ def index(self, req):
341+ return 'no sit and purr with da mockerz.'
342+
343+ class CeilingController(CatController):
344+ @http.httprequest
345+ def lol(self, req):
346+ return 'But der delightz in lawz of Ceiling Cat,'
347+
348+ self.app.load_addons()
349+
350+ body, status, headers = self.client.get('/cat')
351+ self.assertEqual('200 OK', status)
352+ self.assertEqual('no sit and purr with da mockerz.', ''.join(body))
353+ body, status, headers = self.client.get('/cat/lol')
354+ self.assertEqual('200 OK', status)
355+ self.assertEqual('But der delightz in lawz of Ceiling Cat,',
356+ ''.join(body))
357+
358+ def test_override_method(self):
359+ class CatController(http.Controller):
360+ _cp_path = '/cat'
361+
362+ @http.httprequest
363+ def index(self, req):
364+ return 'an ponderz'
365+
366+ class CeilingController(CatController):
367+ @http.httprequest
368+ def index(self, req):
369+ return '%s much.' % super(CeilingController, self).index(req)
370+
371+ self.app.load_addons()
372+
373+ body, status, headers = self.client.get('/cat')
374+ self.assertEqual('200 OK', status)
375+ self.assertEqual('an ponderz much.', ''.join(body))
376+
377+ def test_make_invisible(self):
378+ class CatController(http.Controller):
379+ _cp_path = '/cat'
380+
381+ @http.httprequest
382+ def index(self, req):
383+ return 'Tehy liek treez bai teh waterz,'
384+
385+ class CeilingController(CatController):
386+ def index(self, req):
387+ return super(CeilingController, self).index(req)
388+
389+ self.app.load_addons()
390+
391+ body, status, headers = self.client.get('/cat')
392+ self.assertEqual('404 NOT FOUND', status)
393+
394+ def test_make_json_invisible(self):
395+ class CatController(http.Controller):
396+ _cp_path = '/cat'
397+
398+ @http.jsonrequest
399+ def index(self, req):
400+ return 'Tehy liek treez bai teh waterz,'
401+
402+ class CeilingController(CatController):
403+ def index(self, req):
404+ return super(CeilingController, self).index(req)
405+
406+ self.app.load_addons()
407+
408+ body, status, headers = self.client.post('/cat')
409+ self.assertEqual('404 NOT FOUND', status)
410+
411+ def test_extends(self):
412+ """
413+ When subclassing an existing Controller new classes are "merged" into
414+ the base one
415+ """
416+ class A(http.Controller):
417+ _cp_path = '/foo'
418+ @http.httprequest
419+ def index(self, req):
420+ return '1'
421+
422+ class B(A):
423+ @http.httprequest
424+ def index(self, req):
425+ return "%s 2" % super(B, self).index(req)
426+
427+ class C(A):
428+ @http.httprequest
429+ def index(self, req):
430+ return "%s 3" % super(C, self).index(req)
431+
432+ self.app.load_addons()
433+
434+ body, status, headers = self.client.get('/foo')
435+ self.assertEqual('200 OK', status)
436+ self.assertEqual('1 2 3', ''.join(body))
437+
438+ def test_extends_same_path(self):
439+ """
440+ When subclassing an existing Controller and specifying the same
441+ _cp_path as the parent, ???
442+ """
443+ class A(http.Controller):
444+ _cp_path = '/foo'
445+ @http.httprequest
446+ def index(self, req):
447+ return '1'
448+
449+ class B(A):
450+ _cp_path = '/foo'
451+ @http.httprequest
452+ def index(self, req):
453+ return '2'
454+
455+ self.app.load_addons()
456+
457+ body, status, headers = self.client.get('/foo')
458+ self.assertEqual('200 OK', status)
459+ self.assertEqual('2', ''.join(body))
460+
461+ def test_re_expose(self):
462+ """
463+ An existing Controller should not be extended with a new cp_path
464+ (re-exposing somewhere else)
465+ """
466+ class CatController(http.Controller):
467+ _cp_path = '/cat'
468+
469+ @http.httprequest
470+ def index(self, req):
471+ return '[%s]' % self.speak()
472+
473+ def speak(self):
474+ return 'Yu ordered cheezburgerz,'
475+
476+ with capture_logging('openerp.addons.web.http') as handler:
477+ class DogController(CatController):
478+ _cp_path = '/dog'
479+
480+ def speak(self):
481+ return 'Woof woof woof woof'
482+
483+ [record] = handler.buffer
484+ self.assertEqual(logging.WARN, record.levelno)
485+ self.assertEqual("Re-exposing CatController at /dog.\n"
486+ "\tThis usage is unsupported.",
487+ record.getMessage())
488+
489+ def test_fail_redefine(self):
490+ """
491+ An existing Controller can't be overwritten by a new one on the same
492+ path (? or should this generate a warning and still work as if it was
493+ an extend?)
494+ """
495+ class FooController(http.Controller):
496+ _cp_path = '/foo'
497+
498+ with self.assertRaises(AssertionError):
499+ class BarController(http.Controller):
500+ _cp_path = '/foo'
501+
502+ def test_fail_no_path(self):
503+ """
504+ A Controller must have a path (and thus be exposed)
505+ """
506+ with self.assertRaises(AssertionError):
507+ class FooController(http.Controller):
508+ pass
509+
510+ def test_mixin(self):
511+ """
512+ Can mix "normal" python classes into a controller directly
513+ """
514+ class Mixin(object):
515+ @http.httprequest
516+ def index(self, req):
517+ return 'ok'
518+
519+ class FooController(http.Controller, Mixin):
520+ _cp_path = '/foo'
521+
522+ class BarContoller(Mixin, http.Controller):
523+ _cp_path = '/bar'
524+
525+ self.app.load_addons()
526+
527+ body, status, headers = self.client.get('/foo')
528+ self.assertEqual('200 OK', status)
529+ self.assertEqual('ok', ''.join(body))
530+
531+ body, status, headers = self.client.get('/bar')
532+ self.assertEqual('200 OK', status)
533+ self.assertEqual('ok', ''.join(body))
534+
535+ def test_mixin_extend(self):
536+ """
537+ Can mix "normal" python class into a controller by extension
538+ """
539+ class FooController(http.Controller):
540+ _cp_path = '/foo'
541+
542+ class M1(object):
543+ @http.httprequest
544+ def m1(self, req):
545+ return 'ok 1'
546+
547+ class M2(object):
548+ @http.httprequest
549+ def m2(self, req):
550+ return 'ok 2'
551+
552+ class AddM1(FooController, M1):
553+ pass
554+
555+ class AddM2(M2, FooController):
556+ pass
557+
558+ self.app.load_addons()
559+
560+ body, status, headers = self.client.get('/foo/m1')
561+ self.assertEqual('200 OK', status)
562+ self.assertEqual('ok 1', ''.join(body))
563+
564+ body, status, headers = self.client.get('/foo/m2')
565+ self.assertEqual('200 OK', status)
566+ self.assertEqual('ok 2', ''.join(body))
567
568=== modified file 'addons/web_diagram/controllers/main.py'
569--- addons/web_diagram/controllers/main.py 2012-12-06 09:51:23 +0000
570+++ addons/web_diagram/controllers/main.py 2013-04-18 09:47:37 +0000
571@@ -1,9 +1,10 @@
572-import openerp
573-
574-class DiagramView(openerp.addons.web.controllers.main.View):
575+from openerp.addons.web.http import Controller, jsonrequest
576+
577+
578+class Diagram(Controller):
579 _cp_path = "/web_diagram/diagram"
580
581- @openerp.addons.web.http.jsonrequest
582+ @jsonrequest
583 def get_diagram_info(self, req, id, model, node, connector,
584 src_node, des_node, label, **kw):
585