[MERGE] sitemap backport, merge trunk from 9438 til 9440
[odoo/odoo.git] / addons / website / controllers / main.py
1 # -*- coding: utf-8 -*-
2 import cStringIO
3 import contextlib
4 import hashlib
5 import json
6 import logging
7 import os
8 import datetime
9 import re
10
11 from sys import maxint
12
13 import werkzeug
14 import werkzeug.exceptions
15 import werkzeug.utils
16 import werkzeug.wrappers
17 from PIL import Image
18
19 import openerp
20 from openerp.osv import fields
21 from openerp.addons.website.models import website
22 from openerp.addons.web import http
23 from openerp.http import request, Response
24
25 logger = logging.getLogger(__name__)
26
27 # Completely arbitrary limits
28 MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT = IMAGE_LIMITS = (1024, 768)
29 LOC_PER_SITEMAP = 45000
30
31 class Website(openerp.addons.web.controllers.main.Home):
32     #------------------------------------------------------
33     # View
34     #------------------------------------------------------
35     @http.route('/', type='http', auth="public", website=True, multilang=True)
36     def index(self, **kw):
37         try:
38             main_menu = request.registry['ir.model.data'].get_object(request.cr, request.uid, 'website', 'main_menu')
39             first_menu = main_menu.child_id and main_menu.child_id[0]
40             # Dont 302 loop on /
41             if first_menu and not ((first_menu.url == '/') or first_menu.url.startswith('/#') or first_menu.url.startswith('/?')):
42                 return request.redirect(first_menu.url)
43         except:
44             pass
45         return self.page("website.homepage")
46
47     @http.route(website=True, auth="public", multilang=True)
48     def web_login(self, *args, **kw):
49         # TODO: can't we just put auth=public, ... in web client ?
50         return super(Website, self).web_login(*args, **kw)
51
52     @http.route('/page/<page:page>', type='http', auth="public", website=True, multilang=True)
53     def page(self, page, **opt):
54         values = {
55             'path': page,
56         }
57         # allow shortcut for /page/<website_xml_id>
58         if '.' not in page:
59             page = 'website.%s' % page
60
61         try:
62             request.website.get_template(page)
63         except ValueError, e:
64             # page not found
65             if request.context['editable']:
66                 page = 'website.page_404'
67             else:
68                 return request.registry['ir.http']._handle_exception(e, 404)
69
70         return request.render(page, values)
71
72     @http.route(['/robots.txt'], type='http', auth="public")
73     def robots(self):
74         return request.render('website.robots', {'url_root': request.httprequest.url_root}, mimetype='text/plain')
75
76     @http.route('/sitemap.xml', type='http', auth="public", website=True)
77     def sitemap_xml_index(self):
78         pages = list(request.website.enumerate_pages())
79         if len(pages)<=LOC_PER_SITEMAP:
80             return self.__sitemap_xml(pages, 0)
81         # Sitemaps must be split in several smaller files with a sitemap index
82         values = {
83             'pages': range(len(pages)/LOC_PER_SITEMAP+1),
84             'url_root': request.httprequest.url_root
85         }
86         headers = {
87             'Content-Type': 'application/xml;charset=utf-8',
88         }
89         return request.render('website.sitemap_index_xml', values, headers=headers)
90
91     @http.route('/sitemap-<int:page>.xml', type='http', auth="public", website=True)
92     def sitemap_xml(self, page):
93         pages = list(request.website.enumerate_pages())
94         return self.__sitemap_xml(pages, page)
95
96     def __sitemap_xml(self, pages, index=0):
97         values = {
98             'pages': pages[index*LOC_PER_SITEMAP:(index+1)*LOC_PER_SITEMAP],
99             'url_root': request.httprequest.url_root.rstrip('/')
100         }
101         headers = {
102             'Content-Type': 'application/xml;charset=utf-8',
103         }
104         return request.render('website.sitemap_xml', values, headers=headers)
105
106     #------------------------------------------------------
107     # Edit
108     #------------------------------------------------------
109     @http.route('/website/add/<path:path>', type='http', auth="user", website=True)
110     def pagenew(self, path, noredirect=False, add_menu=None):
111         xml_id = request.registry['website'].new_page(request.cr, request.uid, path, context=request.context)
112         if add_menu:
113             model, id  = request.registry["ir.model.data"].get_object_reference(request.cr, request.uid, 'website', 'main_menu')
114             request.registry['website.menu'].create(request.cr, request.uid, {
115                     'name': path,
116                     'url': "/page/" + xml_id,
117                     'parent_id': id,
118                 }, context=request.context)
119         # Reverse action in order to allow shortcut for /page/<website_xml_id>
120         url = "/page/" + re.sub(r"^website\.", '', xml_id)
121
122         if noredirect:
123             return werkzeug.wrappers.Response(url, mimetype='text/plain')
124         return werkzeug.utils.redirect(url)
125
126     @http.route('/website/theme_change', type='http', auth="user", website=True)
127     def theme_change(self, theme_id=False, **kwargs):
128         imd = request.registry['ir.model.data']
129         view = request.registry['ir.ui.view']
130
131         view_model, view_option_id = imd.get_object_reference(
132             request.cr, request.uid, 'website', 'theme')
133         views = view.search(
134             request.cr, request.uid, [('inherit_id', '=', view_option_id)],
135             context=request.context)
136         view.write(request.cr, request.uid, views, {'inherit_id': False},
137                    context=request.context)
138
139         if theme_id:
140             module, xml_id = theme_id.split('.')
141             view_model, view_id = imd.get_object_reference(
142                 request.cr, request.uid, module, xml_id)
143             view.write(request.cr, request.uid, [view_id],
144                        {'inherit_id': view_option_id}, context=request.context)
145
146         return request.render('website.themes', {'theme_changed': True})
147
148     @http.route(['/website/snippets'], type='json', auth="public", website=True)
149     def snippets(self):
150         return request.website._render('website.snippets')
151
152     @http.route('/website/reset_templates', type='http', auth='user', methods=['POST'], website=True)
153     def reset_template(self, templates, redirect='/'):
154         templates = request.httprequest.form.getlist('templates')
155         modules_to_update = []
156         for temp_id in templates:
157             view = request.registry['ir.ui.view'].browse(request.cr, request.uid, int(temp_id), context=request.context)
158             view.model_data_id.write({
159                 'noupdate': False
160             })
161             if view.model_data_id.module not in modules_to_update:
162                 modules_to_update.append(view.model_data_id.module)
163         module_obj = request.registry['ir.module.module']
164         module_ids = module_obj.search(request.cr, request.uid, [('name', 'in', modules_to_update)], context=request.context)
165         module_obj.button_immediate_upgrade(request.cr, request.uid, module_ids, context=request.context)
166         return request.redirect(redirect)
167
168     @http.route('/website/customize_template_toggle', type='json', auth='user', website=True)
169     def customize_template_set(self, view_id):
170         view_obj = request.registry.get("ir.ui.view")
171         view = view_obj.browse(request.cr, request.uid, int(view_id),
172                                context=request.context)
173         if view.inherit_id:
174             value = False
175         else:
176             value = view.inherit_option_id and view.inherit_option_id.id or False
177         view_obj.write(request.cr, request.uid, [view_id], {
178             'inherit_id': value
179         }, context=request.context)
180         return True
181
182     @http.route('/website/customize_template_get', type='json', auth='user', website=True)
183     def customize_template_get(self, xml_id, optional=True):
184         imd = request.registry['ir.model.data']
185         view_model, view_theme_id = imd.get_object_reference(
186             request.cr, request.uid, 'website', 'theme')
187
188         user = request.registry['res.users'].browse(request.cr, request.uid, request.uid, request.context)
189         group_ids = [g.id for g in user.groups_id]
190
191         view = request.registry.get("ir.ui.view")
192         views = view._views_get(request.cr, request.uid, xml_id, context=request.context)
193         done = {}
194         result = []
195         for v in views:
196             if v.groups_id and [g for g in v.groups_id if g.id not in group_ids]:
197                 continue
198             if v.inherit_option_id and v.inherit_option_id.id != view_theme_id or not optional:
199                 if v.inherit_option_id.id not in done:
200                     result.append({
201                         'name': v.inherit_option_id.name,
202                         'id': v.id,
203                         'xml_id': v.xml_id,
204                         'inherit_id': v.inherit_id.id,
205                         'header': True,
206                         'active': False
207                     })
208                     done[v.inherit_option_id.id] = True
209                 result.append({
210                     'name': v.name,
211                     'id': v.id,
212                     'xml_id': v.xml_id,
213                     'inherit_id': v.inherit_id.id,
214                     'header': False,
215                     'active': (v.inherit_id.id == v.inherit_option_id.id) or (not optional and v.inherit_id.id)
216                 })
217         return result
218
219     @http.route('/website/get_view_translations', type='json', auth='public', website=True)
220     def get_view_translations(self, xml_id, lang=None):
221         lang = lang or request.context.get('lang')
222         views = self.customize_template_get(xml_id, optional=False)
223         views_ids = [view.get('id') for view in views if view.get('active')]
224         domain = [('type', '=', 'view'), ('res_id', 'in', views_ids), ('lang', '=', lang)]
225         irt = request.registry.get('ir.translation')
226         return irt.search_read(request.cr, request.uid, domain, ['id', 'res_id', 'value','state','gengo_translation'], context=request.context)
227
228     @http.route('/website/set_translations', type='json', auth='public', website=True)
229     def set_translations(self, data, lang):
230         irt = request.registry.get('ir.translation')
231         for view_id, trans in data.items():
232             view_id = int(view_id)
233             for t in trans:
234                 initial_content = t['initial_content'].strip()
235                 new_content = t['new_content'].strip()
236                 tid = t['translation_id']
237                 if not tid:
238                     old_trans = irt.search_read(
239                         request.cr, request.uid,
240                         [
241                             ('type', '=', 'view'),
242                             ('res_id', '=', view_id),
243                             ('lang', '=', lang),
244                             ('src', '=', initial_content),
245                         ])
246                     if old_trans:
247                         tid = old_trans[0]['id']
248                 if tid:
249                     vals = {'value': new_content}
250                     irt.write(request.cr, request.uid, [tid], vals)
251                 else:
252                     new_trans = {
253                         'name': 'website',
254                         'res_id': view_id,
255                         'lang': lang,
256                         'type': 'view',
257                         'source': initial_content,
258                         'value': new_content,
259                     }
260                     if t.get('gengo_translation'):
261                         new_trans['gengo_translation'] = t.get('gengo_translation')
262                         new_trans['gengo_comment'] = t.get('gengo_comment')
263                     irt.create(request.cr, request.uid, new_trans)
264         return True
265
266     @http.route('/website/attach', type='http', auth='user', methods=['POST'], website=True)
267     def attach(self, func, upload=None, url=None):
268         Attachments = request.registry['ir.attachment']
269
270         website_url = message = None
271         if not upload:
272             website_url = url
273             name = url.split("/").pop()
274             attachment_id = Attachments.create(request.cr, request.uid, {
275                 'name':name,
276                 'type': 'url',
277                 'url': url,
278                 'res_model': 'ir.ui.view',
279             }, request.context)
280         else:
281             try:
282                 image_data = upload.read()
283                 image = Image.open(cStringIO.StringIO(image_data))
284                 w, h = image.size
285                 if w*h > 42e6: # Nokia Lumia 1020 photo resolution
286                     raise ValueError(
287                         u"Image size excessive, uploaded images must be smaller "
288                         u"than 42 million pixel")
289
290                 attachment_id = Attachments.create(request.cr, request.uid, {
291                     'name': upload.filename,
292                     'datas': image_data.encode('base64'),
293                     'datas_fname': upload.filename,
294                     'res_model': 'ir.ui.view',
295                 }, request.context)
296
297                 [attachment] = Attachments.read(
298                     request.cr, request.uid, [attachment_id], ['website_url'],
299                     context=request.context)
300                 website_url = attachment['website_url']
301             except Exception, e:
302                 logger.exception("Failed to upload image to attachment")
303                 message = unicode(e)
304
305         return """<script type='text/javascript'>
306             window.parent['%s'](%s, %s);
307         </script>""" % (func, json.dumps(website_url), json.dumps(message))
308
309     @http.route(['/website/publish'], type='json', auth="public", website=True)
310     def publish(self, id, object):
311         _id = int(id)
312         _object = request.registry[object]
313         obj = _object.browse(request.cr, request.uid, _id)
314
315         values = {}
316         if 'website_published' in _object._all_columns:
317             values['website_published'] = not obj.website_published
318         _object.write(request.cr, request.uid, [_id],
319                       values, context=request.context)
320
321         obj = _object.browse(request.cr, request.uid, _id)
322         return bool(obj.website_published)
323
324     #------------------------------------------------------
325     # Helpers
326     #------------------------------------------------------
327     @http.route(['/website/kanban'], type='http', auth="public", methods=['POST'], website=True)
328     def kanban(self, **post):
329         return request.website.kanban_col(**post)
330
331     def placeholder(self, response):
332         # file_open may return a StringIO. StringIO can be closed but are
333         # not context managers in Python 2 though that is fixed in 3
334         with contextlib.closing(openerp.tools.misc.file_open(
335                 os.path.join('web', 'static', 'src', 'img', 'placeholder.png'),
336                 mode='rb')) as f:
337             response.data = f.read()
338             return response.make_conditional(request.httprequest)
339
340     @http.route([
341         '/website/image',
342         '/website/image/<model>/<id>/<field>'
343         ], auth="public", website=True)
344     def website_image(self, model, id, field, max_width=maxint, max_height=maxint):
345         """ Fetches the requested field and ensures it does not go above
346         (max_width, max_height), resizing it if necessary.
347
348         Resizing is bypassed if the object provides a $field_big, which will
349         be interpreted as a pre-resized version of the base field.
350
351         If the record is not found or does not have the requested field,
352         returns a placeholder image via :meth:`~.placeholder`.
353
354         Sets and checks conditional response parameters:
355         * :mailheader:`ETag` is always set (and checked)
356         * :mailheader:`Last-Modified is set iif the record has a concurrency
357           field (``__last_update``)
358
359         The requested field is assumed to be base64-encoded image data in
360         all cases.
361         """
362         Model = request.registry[model]
363
364         response = werkzeug.wrappers.Response()
365
366         id = int(id)
367
368         ids = Model.search(request.cr, request.uid,
369                            [('id', '=', id)], context=request.context)
370         if not ids and 'website_published' in Model._all_columns:
371             ids = Model.search(request.cr, openerp.SUPERUSER_ID,
372                                [('id', '=', id), ('website_published', '=', True)], context=request.context)
373
374         if not ids:
375             return self.placeholder(response)
376
377         presized = '%s_big' % field
378         concurrency = '__last_update'
379         [record] = Model.read(request.cr, openerp.SUPERUSER_ID, [id],
380                               [concurrency, field, presized],
381                               context=request.context)
382
383         if concurrency in record:
384             server_format = openerp.tools.misc.DEFAULT_SERVER_DATETIME_FORMAT
385             try:
386                 response.last_modified = datetime.datetime.strptime(
387                     record[concurrency], server_format + '.%f')
388             except ValueError:
389                 # just in case we have a timestamp without microseconds
390                 response.last_modified = datetime.datetime.strptime(
391                     record[concurrency], server_format)
392
393         # Field does not exist on model or field set to False
394         if not record.get(field):
395             # FIXME: maybe a field which does not exist should be a 404?
396             return self.placeholder(response)
397
398         response.set_etag(hashlib.sha1(record[field]).hexdigest())
399         response.make_conditional(request.httprequest)
400
401         # conditional request match
402         if response.status_code == 304:
403             return response
404
405         data = (record.get(presized) or record[field]).decode('base64')
406
407         image = Image.open(cStringIO.StringIO(data))
408         response.mimetype = Image.MIME[image.format]
409
410         # record provides a pre-resized version of the base field, use that
411         # directly
412         if record.get(presized):
413             response.data = data
414             return response
415
416         fit = int(max_width), int(max_height)
417         w, h = image.size
418         max_w, max_h = fit
419
420         if w < max_w and h < max_h:
421             response.data = data
422         else:
423             image.thumbnail(fit, Image.ANTIALIAS)
424             image.save(response.stream, image.format)
425             # invalidate content-length computed by make_conditional as
426             # writing to response.stream does not do it (as of werkzeug 0.9.3)
427             del response.headers['Content-Length']
428
429         return response
430
431     #------------------------------------------------------
432     # Server actions
433     #------------------------------------------------------
434     @http.route('/website/action/<path_or_xml_id_or_id>', type='http', auth="public", website=True)
435     def actions_server(self, path_or_xml_id_or_id, **post):
436         cr, uid, context = request.cr, request.uid, request.context
437         res, action_id, action = None, None, None
438         ServerActions = request.registry['ir.actions.server']
439
440         # find the action_id: either an xml_id, the path, or an ID
441         if isinstance(path_or_xml_id_or_id, basestring) and '.' in path_or_xml_id_or_id:
442             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)
443         if not action_id:
444             action_ids = ServerActions.search(cr, uid, [('website_path', '=', path_or_xml_id_or_id), ('website_published', '=', True)], context=context)
445             action_id = action_ids and action_ids[0] or None
446         if not action_id:
447             try:
448                 action_id = int(path_or_xml_id_or_id)
449             except ValueError:
450                 pass
451
452         # check it effectively exists
453         if action_id:
454             action_ids = ServerActions.exists(cr, uid, [action_id], context=context)
455             action_id = action_ids and action_ids[0] or None
456         # run it, return only if we got a Response object
457         if action_id:
458             action = ServerActions.browse(cr, uid, action_id, context=context)
459             if action.state == 'code' and action.website_published:
460                 action_res = ServerActions.run(cr, uid, [action_id], context=context)
461                 if isinstance(action_res, Response):
462                     res = action_res
463         if res:
464             return res
465         return request.redirect('/')
466