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