[MERGE] forward port of branch 8.0 up to e883193
[odoo/odoo.git] / addons / website / controllers / main.py
1 # -*- coding: utf-8 -*-
2 import cStringIO
3 import datetime
4 from itertools import islice
5 import json
6 import xml.etree.ElementTree as ET
7
8 import logging
9 import re
10
11 import werkzeug.utils
12 import urllib2
13 import werkzeug.wrappers
14 from PIL import Image
15
16 import openerp
17 from openerp.addons.web import http
18 from openerp.http import request, STATIC_CACHE
19 from openerp.tools import image_save_for_web
20
21 logger = logging.getLogger(__name__)
22
23 # Completely arbitrary limits
24 MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT = IMAGE_LIMITS = (1024, 768)
25 LOC_PER_SITEMAP = 45000
26 SITEMAP_CACHE_TIME = datetime.timedelta(hours=12)
27
28 class Website(openerp.addons.web.controllers.main.Home):
29     #------------------------------------------------------
30     # View
31     #------------------------------------------------------
32     @http.route('/', type='http', auth="public", website=True)
33     def index(self, **kw):
34         page = 'homepage'
35         try:
36             main_menu = request.registry['ir.model.data'].get_object(request.cr, request.uid, 'website', 'main_menu')
37         except Exception:
38             pass
39         else:
40             first_menu = main_menu.child_id and main_menu.child_id[0]
41             if first_menu:
42                 if not (first_menu.url.startswith(('/page/', '/?', '/#')) or (first_menu.url=='/')):
43                     return request.redirect(first_menu.url)
44                 if first_menu.url.startswith('/page/'):
45                     return request.registry['ir.http'].reroute(first_menu.url)
46         return self.page(page)
47
48     @http.route(website=True, auth="public")
49     def web_login(self, *args, **kw):
50         # TODO: can't we just put auth=public, ... in web client ?
51         return super(Website, self).web_login(*args, **kw)
52
53     @http.route('/page/<page:page>', type='http', auth="public", website=True)
54     def page(self, page, **opt):
55         values = {
56             'path': page,
57         }
58         # /page/website.XXX --> /page/XXX
59         if page.startswith('website.'):
60             return request.redirect('/page/' + page[8:], code=301)
61         elif '.' not in page:
62             page = 'website.%s' % page
63
64         try:
65             request.website.get_template(page)
66         except ValueError, e:
67             # page not found
68             if request.website.is_publisher():
69                 page = 'website.page_404'
70             else:
71                 return request.registry['ir.http']._handle_exception(e, 404)
72
73         return request.render(page, values)
74
75     @http.route(['/robots.txt'], type='http', auth="public")
76     def robots(self):
77         return request.render('website.robots', {'url_root': request.httprequest.url_root}, mimetype='text/plain')
78
79     @http.route('/sitemap.xml', type='http', auth="public", website=True)
80     def sitemap_xml_index(self):
81         cr, uid, context = request.cr, openerp.SUPERUSER_ID, request.context
82         ira = request.registry['ir.attachment']
83         iuv = request.registry['ir.ui.view']
84         mimetype ='application/xml;charset=utf-8'
85         content = None
86
87         def create_sitemap(url, content):
88             ira.create(cr, uid, dict(
89                 datas=content.encode('base64'),
90                 mimetype=mimetype,
91                 type='binary',
92                 name=url,
93                 url=url,
94             ), context=context)
95
96         sitemap = ira.search_read(cr, uid, [('url', '=' , '/sitemap.xml'), ('type', '=', 'binary')], ('datas', 'create_date'), context=context)
97         if sitemap:
98             # Check if stored version is still valid
99             server_format = openerp.tools.misc.DEFAULT_SERVER_DATETIME_FORMAT
100             create_date = datetime.datetime.strptime(sitemap[0]['create_date'], server_format)
101             delta = datetime.datetime.now() - create_date
102             if delta < SITEMAP_CACHE_TIME:
103                 content = sitemap[0]['datas'].decode('base64')
104
105         if not content:
106             # Remove all sitemaps in ir.attachments as we're going to regenerated them
107             sitemap_ids = ira.search(cr, uid, [('url', '=like' , '/sitemap%.xml'), ('type', '=', 'binary')], context=context)
108             if sitemap_ids:
109                 ira.unlink(cr, uid, sitemap_ids, context=context)
110
111             pages = 0
112             first_page = None
113             locs = request.website.enumerate_pages()
114             while True:
115                 start = pages * LOC_PER_SITEMAP
116                 values = {
117                     'locs': islice(locs, start, start + LOC_PER_SITEMAP),
118                     'url_root': request.httprequest.url_root[:-1],
119                 }
120                 urls = iuv.render(cr, uid, 'website.sitemap_locs', values, context=context)
121                 if urls.strip():
122                     page = iuv.render(cr, uid, 'website.sitemap_xml', dict(content=urls), context=context)
123                     if not first_page:
124                         first_page = page
125                     pages += 1
126                     create_sitemap('/sitemap-%d.xml' % pages, page)
127                 else:
128                     break
129             if not pages:
130                 return request.not_found()
131             elif pages == 1:
132                 content = first_page
133             else:
134                 # Sitemaps must be split in several smaller files with a sitemap index
135                 content = iuv.render(cr, uid, 'website.sitemap_index_xml', dict(
136                     pages=range(1, pages + 1),
137                     url_root=request.httprequest.url_root,
138                 ), context=context)
139             create_sitemap('/sitemap.xml', content)
140
141         return request.make_response(content, [('Content-Type', mimetype)])
142
143     @http.route('/website/info', type='http', auth="public", website=True)
144     def website_info(self):
145         try:
146             request.website.get_template('website.info').name
147         except Exception, e:
148             return request.registry['ir.http']._handle_exception(e, 404)
149         irm = request.env()['ir.module.module'].sudo()
150         apps = irm.search([('state','=','installed'),('application','=',True)])
151         modules = irm.search([('state','=','installed'),('application','=',False)])
152         values = {
153             'apps': apps,
154             'modules': modules,
155             'version': openerp.service.common.exp_version()
156         }
157         return request.render('website.info', values)
158
159     #------------------------------------------------------
160     # Edit
161     #------------------------------------------------------
162     @http.route('/website/add/<path:path>', type='http', auth="user", website=True)
163     def pagenew(self, path, noredirect=False, add_menu=None):
164         xml_id = request.registry['website'].new_page(request.cr, request.uid, path, context=request.context)
165         if add_menu:
166             request.registry['website.menu'].create(request.cr, request.uid, {
167                     'name': path,
168                     'url': "/page/" + xml_id,
169                     'parent_id': request.website.menu_id.id,
170                     'website_id': request.website.id,
171                 }, context=request.context)
172         # Reverse action in order to allow shortcut for /page/<website_xml_id>
173         url = "/page/" + re.sub(r"^website\.", '', xml_id)
174
175         if noredirect:
176             return werkzeug.wrappers.Response(url, mimetype='text/plain')
177         return werkzeug.utils.redirect(url)
178
179     @http.route(['/website/snippets'], type='json', auth="public", website=True)
180     def snippets(self):
181         return request.website._render('website.snippets')
182
183     @http.route('/website/reset_templates', type='http', auth='user', methods=['POST'], website=True)
184     def reset_template(self, templates, redirect='/'):
185         templates = request.httprequest.form.getlist('templates')
186         modules_to_update = []
187         for temp_id in templates:
188             view = request.registry['ir.ui.view'].browse(request.cr, request.uid, int(temp_id), context=request.context)
189             if view.page:
190                 continue
191             view.model_data_id.write({
192                 'noupdate': False
193             })
194             if view.model_data_id.module not in modules_to_update:
195                 modules_to_update.append(view.model_data_id.module)
196
197         if modules_to_update:
198             module_obj = request.registry['ir.module.module']
199             module_ids = module_obj.search(request.cr, request.uid, [('name', 'in', modules_to_update)], context=request.context)
200             if module_ids:
201                 module_obj.button_immediate_upgrade(request.cr, request.uid, module_ids, context=request.context)
202         return request.redirect(redirect)
203
204     @http.route('/website/customize_template_get', type='json', auth='user', website=True)
205     def customize_template_get(self, xml_id, full=False, bundles=False):
206         """ Lists the templates customizing ``xml_id``. By default, only
207         returns optional templates (which can be toggled on and off), if
208         ``full=True`` returns all templates customizing ``xml_id``
209         ``bundles=True`` returns also the asset bundles
210         """
211         imd = request.registry['ir.model.data']
212         view_model, view_theme_id = imd.get_object_reference(
213             request.cr, request.uid, 'website', 'theme')
214
215         user = request.registry['res.users']\
216             .browse(request.cr, request.uid, request.uid, request.context)
217         user_groups = set(user.groups_id)
218
219         views = request.registry["ir.ui.view"]\
220             ._views_get(request.cr, request.uid, xml_id, bundles=bundles, context=dict(request.context or {}, active_test=False))
221         done = set()
222         result = []
223         for v in views:
224             if not user_groups.issuperset(v.groups_id):
225                 continue
226             if full or (v.customize_show and v.inherit_id.id != view_theme_id):
227                 if v.inherit_id not in done:
228                     result.append({
229                         'name': v.inherit_id.name,
230                         'id': v.id,
231                         'xml_id': v.xml_id,
232                         'inherit_id': v.inherit_id.id,
233                         'header': True,
234                         'active': False
235                     })
236                     done.add(v.inherit_id)
237                 result.append({
238                     'name': v.name,
239                     'id': v.id,
240                     'xml_id': v.xml_id,
241                     'inherit_id': v.inherit_id.id,
242                     'header': False,
243                     'active': v.active,
244                 })
245         return result
246
247     @http.route('/website/get_view_translations', type='json', auth='public', website=True)
248     def get_view_translations(self, xml_id, lang=None):
249         lang = lang or request.context.get('lang')
250         views = self.customize_template_get(xml_id, full=True)
251         views_ids = [view.get('id') for view in views if view.get('active')]
252         domain = [('type', '=', 'view'), ('res_id', 'in', views_ids), ('lang', '=', lang)]
253         irt = request.registry.get('ir.translation')
254         return irt.search_read(request.cr, request.uid, domain, ['id', 'res_id', 'value','state','gengo_translation'], context=request.context)
255
256     @http.route('/website/set_translations', type='json', auth='public', website=True)
257     def set_translations(self, data, lang):
258         irt = request.registry.get('ir.translation')
259         for view_id, trans in data.items():
260             view_id = int(view_id)
261             for t in trans:
262                 initial_content = t['initial_content'].strip()
263                 new_content = t['new_content'].strip()
264                 tid = t['translation_id']
265                 if not tid:
266                     old_trans = irt.search_read(
267                         request.cr, request.uid,
268                         [
269                             ('type', '=', 'view'),
270                             ('res_id', '=', view_id),
271                             ('lang', '=', lang),
272                             ('src', '=', initial_content),
273                         ])
274                     if old_trans:
275                         tid = old_trans[0]['id']
276                 if tid:
277                     vals = {'value': new_content}
278                     irt.write(request.cr, request.uid, [tid], vals)
279                 else:
280                     new_trans = {
281                         'name': 'website',
282                         'res_id': view_id,
283                         'lang': lang,
284                         'type': 'view',
285                         'source': initial_content,
286                         'value': new_content,
287                     }
288                     if t.get('gengo_translation'):
289                         new_trans['gengo_translation'] = t.get('gengo_translation')
290                         new_trans['gengo_comment'] = t.get('gengo_comment')
291                     irt.create(request.cr, request.uid, new_trans)
292         return True
293
294     @http.route('/website/attach', type='http', auth='user', methods=['POST'], website=True)
295     def attach(self, func, upload=None, url=None, disable_optimization=None):
296         # the upload argument doesn't allow us to access the files if more than
297         # one file is uploaded, as upload references the first file
298         # therefore we have to recover the files from the request object
299         Attachments = request.registry['ir.attachment']  # registry for the attachment table
300
301         uploads = []
302         message = None
303         if not upload: # no image provided, storing the link and the image name
304             uploads.append({'website_url': url})
305             name = url.split("/").pop()                       # recover filename
306             attachment_id = Attachments.create(request.cr, request.uid, {
307                 'name':name,
308                 'type': 'url',
309                 'url': url,
310                 'res_model': 'ir.ui.view',
311             }, request.context)
312         else:                                                  # images provided
313             try:
314                 for c_file in request.httprequest.files.getlist('upload'):
315                     image_data = c_file.read()
316                     image = Image.open(cStringIO.StringIO(image_data))
317                     w, h = image.size
318                     if w*h > 42e6: # Nokia Lumia 1020 photo resolution
319                         raise ValueError(
320                             u"Image size excessive, uploaded images must be smaller "
321                             u"than 42 million pixel")
322     
323                 if not disable_optimization and image.format in ('PNG', 'JPEG'):
324                     image_data = image_save_for_web(image)
325
326                     attachment_id = Attachments.create(request.cr, request.uid, {
327                         'name': c_file.filename,
328                         'datas': image_data.encode('base64'),
329                         'datas_fname': c_file.filename,
330                         'res_model': 'ir.ui.view',
331                     }, request.context)
332     
333                     [attachment] = Attachments.read(
334                         request.cr, request.uid, [attachment_id], ['website_url'],
335                         context=request.context)
336                     uploads.append(attachment)
337             except Exception, e:
338                 logger.exception("Failed to upload image to attachment")
339                 message = unicode(e)
340
341         return """<script type='text/javascript'>
342             window.parent['%s'](%s, %s);
343         </script>""" % (func, json.dumps(uploads), json.dumps(message))
344
345     @http.route(['/website/publish'], type='json', auth="public", website=True)
346     def publish(self, id, object):
347         _id = int(id)
348         _object = request.registry[object]
349         obj = _object.browse(request.cr, request.uid, _id)
350
351         values = {}
352         if 'website_published' in _object._all_columns:
353             values['website_published'] = not obj.website_published
354         _object.write(request.cr, request.uid, [_id],
355                       values, context=request.context)
356
357         obj = _object.browse(request.cr, request.uid, _id)
358         return bool(obj.website_published)
359
360     @http.route(['/website/seo_suggest/<keywords>'], type='http', auth="public", website=True)
361     def seo_suggest(self, keywords):
362         url = "http://google.com/complete/search"
363         try:
364             req = urllib2.Request("%s?%s" % (url, werkzeug.url_encode({
365                 'ie': 'utf8', 'oe': 'utf8', 'output': 'toolbar', 'q': keywords})))
366             request = urllib2.urlopen(req)
367         except (urllib2.HTTPError, urllib2.URLError):
368             return []
369         xmlroot = ET.fromstring(request.read())
370         return json.dumps([sugg[0].attrib['data'] for sugg in xmlroot if len(sugg) and sugg[0].attrib['data']])
371
372     #------------------------------------------------------
373     # Themes
374     #------------------------------------------------------
375
376     def get_view_ids(self, xml_ids):
377         ids = []
378         imd = request.registry['ir.model.data']
379         for xml_id in xml_ids:
380             if "." in xml_id:
381                 xml = xml_id.split(".")
382                 view_model, id = imd.get_object_reference(request.cr, request.uid, xml[0], xml[1])
383             else:
384                 id = int(xml_id)
385             ids.append(id)
386         return ids
387
388     @http.route(['/website/theme_customize_get'], type='json', auth="public", website=True)
389     def theme_customize_get(self, xml_ids):
390         view = request.registry["ir.ui.view"]
391         enable = []
392         disable = []
393         ids = self.get_view_ids(xml_ids)
394         context = dict(request.context or {}, active_test=True)
395         for v in view.browse(request.cr, request.uid, ids, context=context):
396             if v.active:
397                 enable.append(v.xml_id)
398             else:
399                 disable.append(v.xml_id)
400         return [enable, disable]
401
402     @http.route(['/website/theme_customize'], type='json', auth="public", website=True)
403     def theme_customize(self, enable, disable):
404         """ enable or Disable lists of ``xml_id`` of the inherit templates
405         """
406         cr, uid, context, pool = request.cr, request.uid, request.context, request.registry
407         view = pool["ir.ui.view"]
408         context = dict(request.context or {}, active_test=True)
409
410         def set_active(ids, active):
411             if ids:
412                 view.write(cr, uid, self.get_view_ids(ids), {'active': active}, context=context)
413
414         set_active(disable, False)
415         set_active(enable, True)
416
417         return True
418
419     @http.route(['/website/theme_customize_reload'], type='http', auth="public", website=True)
420     def theme_customize_reload(self, href, enable, disable):
421         self.theme_customize(enable and enable.split(",") or [],disable and disable.split(",") or [])
422         return request.redirect(href + ("&theme=true" if "#" in href else "#theme=true"))
423
424     #------------------------------------------------------
425     # Helpers
426     #------------------------------------------------------
427     @http.route(['/website/kanban'], type='http', auth="public", methods=['POST'], website=True)
428     def kanban(self, **post):
429         return request.website.kanban_col(**post)
430
431     def placeholder(self, response):
432         return request.registry['website']._image_placeholder(response)
433
434     @http.route([
435         '/website/image',
436         '/website/image/<xmlid>',
437         '/website/image/<xmlid>/<field>',
438         '/website/image/<model>/<id>/<field>',
439         '/website/image/<model>/<id>/<field>/<int:max_width>x<int:max_height>'
440         ], auth="public", website=True)
441     def website_image(self, model=None, id=None, field=None, xmlid=None, max_width=None, max_height=None):
442         """ Fetches the requested field and ensures it does not go above
443         (max_width, max_height), resizing it if necessary.
444
445         If the record is not found or does not have the requested field,
446         returns a placeholder image via :meth:`~.placeholder`.
447
448         Sets and checks conditional response parameters:
449         * :mailheader:`ETag` is always set (and checked)
450         * :mailheader:`Last-Modified is set iif the record has a concurrency
451           field (``__last_update``)
452
453         The requested field is assumed to be base64-encoded image data in
454         all cases.
455
456         xmlid can be used to load the image. But the field image must by base64-encoded
457         """
458         if xmlid and "." in xmlid:
459             xmlid = xmlid.split(".", 1)
460             try:
461                 model, id = request.registry['ir.model.data'].get_object_reference(request.cr, request.uid, xmlid[0], xmlid[1])
462             except:
463                 raise werkzeug.exceptions.NotFound()
464             if model == 'ir.attachment':
465                 field = "datas"
466
467         if not model or not id or not field:
468             raise werkzeug.exceptions.NotFound()
469
470         try:
471             idsha = id.split('_')
472             id = idsha[0]
473             response = werkzeug.wrappers.Response()
474             return request.registry['website']._image(
475                 request.cr, request.uid, model, id, field, response, max_width, max_height,
476                 cache=STATIC_CACHE if len(idsha) > 1 else None)
477         except Exception:
478             logger.exception("Cannot render image field %r of record %s[%s] at size(%s,%s)",
479                              field, model, id, max_width, max_height)
480             response = werkzeug.wrappers.Response()
481             return self.placeholder(response)
482
483     #------------------------------------------------------
484     # Server actions
485     #------------------------------------------------------
486     @http.route('/website/action/<path_or_xml_id_or_id>', type='http', auth="public", website=True)
487     def actions_server(self, path_or_xml_id_or_id, **post):
488         cr, uid, context = request.cr, request.uid, request.context
489         res, action_id, action = None, None, None
490         ServerActions = request.registry['ir.actions.server']
491
492         # find the action_id: either an xml_id, the path, or an ID
493         if isinstance(path_or_xml_id_or_id, basestring) and '.' in path_or_xml_id_or_id:
494             action_id = request.registry['ir.model.data'].xmlid_to_res_id(request.cr, request.uid, path_or_xml_id_or_id, raise_if_not_found=False)
495         if not action_id:
496             action_ids = ServerActions.search(cr, uid, [('website_path', '=', path_or_xml_id_or_id), ('website_published', '=', True)], context=context)
497             action_id = action_ids and action_ids[0] or None
498         if not action_id:
499             try:
500                 action_id = int(path_or_xml_id_or_id)
501             except ValueError:
502                 pass
503
504         # check it effectively exists
505         if action_id:
506             action_ids = ServerActions.exists(cr, uid, [action_id], context=context)
507             action_id = action_ids and action_ids[0] or None
508         # run it, return only if we got a Response object
509         if action_id:
510             action = ServerActions.browse(cr, uid, action_id, context=context)
511             if action.state == 'code' and action.website_published:
512                 action_res = ServerActions.run(cr, uid, [action_id], context=context)
513                 if isinstance(action_res, werkzeug.wrappers.Response):
514                     res = action_res
515         if res:
516             return res
517         return request.redirect('/')
518