Merge lp:~openerp-dev/openerp-web/7.0-controller-extension-xmo into lp:openerp-web/7.0
- 7.0-controller-extension-xmo
- Merge into 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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
OpenERP Core Team | Pending | ||
Review via email: mp+157151@code.launchpad.net |
Commit message
Description of the change
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 |