[MERGE] forward port of branch 8.0 up to e883193
[odoo/odoo.git] / addons / website / models / website.py
1 # -*- coding: utf-8 -*-
2 import cStringIO
3 import contextlib
4 import datetime
5 import hashlib
6 import inspect
7 import logging
8 import math
9 import mimetypes
10 import unicodedata
11 import os
12 import re
13 import time
14 import urlparse
15
16 from PIL import Image
17 from sys import maxint
18
19 import werkzeug
20 # optional python-slugify import (https://github.com/un33k/python-slugify)
21 try:
22     import slugify as slugify_lib
23 except ImportError:
24     slugify_lib = None
25
26 import openerp
27 from openerp.osv import orm, osv, fields
28 from openerp.tools import html_escape as escape, ustr, image_resize_and_sharpen, image_save_for_web
29 from openerp.tools.safe_eval import safe_eval
30 from openerp.addons.web.http import request
31 from werkzeug.exceptions import NotFound
32
33 logger = logging.getLogger(__name__)
34
35 def url_for(path_or_uri, lang=None):
36     if isinstance(path_or_uri, unicode):
37         path_or_uri = path_or_uri.encode('utf-8')
38     current_path = request.httprequest.path
39     if isinstance(current_path, unicode):
40         current_path = current_path.encode('utf-8')
41     location = path_or_uri.strip()
42     force_lang = lang is not None
43     url = urlparse.urlparse(location)
44
45     if request and not url.netloc and not url.scheme and (url.path or force_lang):
46         location = urlparse.urljoin(current_path, location)
47
48         lang = lang or request.context.get('lang')
49         langs = [lg[0] for lg in request.website.get_languages()]
50
51         if (len(langs) > 1 or force_lang) and is_multilang_url(location, langs):
52             ps = location.split('/')
53             if ps[1] in langs:
54                 # Replace the language only if we explicitly provide a language to url_for
55                 if force_lang:
56                     ps[1] = lang
57                 # Remove the default language unless it's explicitly provided
58                 elif ps[1] == request.website.default_lang_code:
59                     ps.pop(1)
60             # Insert the context language or the provided language
61             elif lang != request.website.default_lang_code or force_lang:
62                 ps.insert(1, lang)
63             location = '/'.join(ps)
64
65     return location.decode('utf-8')
66
67 def is_multilang_url(local_url, langs=None):
68     if not langs:
69         langs = [lg[0] for lg in request.website.get_languages()]
70     spath = local_url.split('/')
71     # if a language is already in the path, remove it
72     if spath[1] in langs:
73         spath.pop(1)
74         local_url = '/'.join(spath)
75     try:
76         # Try to match an endpoint in werkzeug's routing table
77         url = local_url.split('?')
78         path = url[0]
79         query_string = url[1] if len(url) > 1 else None
80         router = request.httprequest.app.get_db_router(request.db).bind('')
81         func = router.match(path, query_args=query_string)[0]
82         return func.routing.get('website', False) and func.routing.get('multilang', True)
83     except Exception:
84         return False
85
86 def slugify(s, max_length=None):
87     """ Transform a string to a slug that can be used in a url path.
88
89     This method will first try to do the job with python-slugify if present.
90     Otherwise it will process string by stripping leading and ending spaces,
91     converting unicode chars to ascii, lowering all chars and replacing spaces
92     and underscore with hyphen "-".
93
94     :param s: str
95     :param max_length: int
96     :rtype: str
97     """
98     s = ustr(s)
99     if slugify_lib:
100         # There are 2 different libraries only python-slugify is supported
101         try:
102             return slugify_lib.slugify(s, max_length=max_length)
103         except TypeError:
104             pass
105     uni = unicodedata.normalize('NFKD', s).encode('ascii', 'ignore').decode('ascii')
106     slug = re.sub('[\W_]', ' ', uni).strip().lower()
107     slug = re.sub('[-\s]+', '-', slug)
108
109     return slug[:max_length]
110
111 def slug(value):
112     if isinstance(value, orm.browse_record):
113         # [(id, name)] = value.name_get()
114         id, name = value.id, value.display_name
115     else:
116         # assume name_search result tuple
117         id, name = value
118     slugname = slugify(name or '').strip().strip('-')
119     if not slugname:
120         return str(id)
121     return "%s-%d" % (slugname, id)
122
123
124 # NOTE: as the pattern is used as it for the ModelConverter (ir_http.py), do not use any flags
125 _UNSLUG_RE = re.compile(r'(?:(\w{1,2}|\w[A-Za-z0-9-_]+?\w)-)?(-?\d+)(?=$|/)')
126
127 def unslug(s):
128     """Extract slug and id from a string.
129         Always return un 2-tuple (str|None, int|None)
130     """
131     m = _UNSLUG_RE.match(s)
132     if not m:
133         return None, None
134     return m.group(1), int(m.group(2))
135
136 def urlplus(url, params):
137     return werkzeug.Href(url)(params or None)
138
139 class website(osv.osv):
140     def _get_menu(self, cr, uid, ids, name, arg, context=None):
141         res = {}
142         menu_obj = self.pool.get('website.menu')
143         for id in ids:
144             menu_ids = menu_obj.search(cr, uid, [('parent_id', '=', False), ('website_id', '=', id)], order='id', context=context)
145             res[id] = menu_ids and menu_ids[0] or False
146         return res
147
148     _name = "website" # Avoid website.website convention for conciseness (for new api). Got a special authorization from xmo and rco
149     _description = "Website"
150     _columns = {
151         'name': fields.char('Website Name'),
152         'domain': fields.char('Website Domain'),
153         'company_id': fields.many2one('res.company', string="Company"),
154         'language_ids': fields.many2many('res.lang', 'website_lang_rel', 'website_id', 'lang_id', 'Languages'),
155         'default_lang_id': fields.many2one('res.lang', string="Default language"),
156         'default_lang_code': fields.related('default_lang_id', 'code', type="char", string="Default language code", store=True),
157         'social_twitter': fields.char('Twitter Account'),
158         'social_facebook': fields.char('Facebook Account'),
159         'social_github': fields.char('GitHub Account'),
160         'social_linkedin': fields.char('LinkedIn Account'),
161         'social_youtube': fields.char('Youtube Account'),
162         'social_googleplus': fields.char('Google+ Account'),
163         'google_analytics_key': fields.char('Google Analytics Key'),
164         'user_id': fields.many2one('res.users', string='Public User'),
165         'compress_html': fields.boolean('Compress HTML'),
166         'partner_id': fields.related('user_id','partner_id', type='many2one', relation='res.partner', string='Public Partner'),
167         'menu_id': fields.function(_get_menu, relation='website.menu', type='many2one', string='Main Menu')
168     }
169     _defaults = {
170         'user_id': lambda self,cr,uid,c: self.pool['ir.model.data'].xmlid_to_res_id(cr, openerp.SUPERUSER_ID, 'base.public_user'),
171         'company_id': lambda self,cr,uid,c: self.pool['ir.model.data'].xmlid_to_res_id(cr, openerp.SUPERUSER_ID,'base.main_company'),
172         'compress_html': False,
173     }
174
175     # cf. Wizard hack in website_views.xml
176     def noop(self, *args, **kwargs):
177         pass
178
179     def write(self, cr, uid, ids, vals, context=None):
180         self._get_languages.clear_cache(self)
181         return super(website, self).write(cr, uid, ids, vals, context)
182
183     def new_page(self, cr, uid, name, template='website.default_page', ispage=True, context=None):
184         context = context or {}
185         imd = self.pool.get('ir.model.data')
186         view = self.pool.get('ir.ui.view')
187         template_module, template_name = template.split('.')
188
189         # completely arbitrary max_length
190         page_name = slugify(name, max_length=50)
191         page_xmlid = "%s.%s" % (template_module, page_name)
192
193         try:
194             # existing page
195             imd.get_object_reference(cr, uid, template_module, page_name)
196         except ValueError:
197             # new page
198             _, template_id = imd.get_object_reference(cr, uid, template_module, template_name)
199             website_id = context.get('website_id')
200             key = template_module+'.'+page_name
201             page_id = view.copy(cr, uid, template_id, {'website_id': website_id, 'key': key}, context=context)
202             page = view.browse(cr, uid, page_id, context=context)
203             page.write({
204                 'arch': page.arch.replace(template, page_xmlid),
205                 'name': page_name,
206                 'page': ispage,
207             })
208         return page_xmlid
209
210     def page_for_name(self, cr, uid, ids, name, module='website', context=None):
211         # whatever
212         return '%s.%s' % (module, slugify(name, max_length=50))
213
214     def page_exists(self, cr, uid, ids, name, module='website', context=None):
215         try:
216             name = (name or "").replace("/page/website.", "").replace("/page/", "")
217             if not name:
218                 return False
219             return self.pool["ir.model.data"].get_object_reference(cr, uid, module, name)
220         except:
221             return False
222
223     @openerp.tools.ormcache(skiparg=3)
224     def _get_languages(self, cr, uid, id, context=None):
225         website = self.browse(cr, uid, id)
226         return [(lg.code, lg.name) for lg in website.language_ids]
227
228     def get_languages(self, cr, uid, ids, context=None):
229         return self._get_languages(cr, uid, ids[0], context=context)
230
231     def get_alternate_languages(self, cr, uid, ids, req=None, context=None):
232         langs = []
233         if req is None:
234             req = request.httprequest
235         default = self.get_current_website(cr, uid, context=context).default_lang_code
236         uri = req.path
237         if req.query_string:
238             uri += '?' + req.query_string
239         shorts = []
240         for code, name in self.get_languages(cr, uid, ids, context=context):
241             lg_path = ('/' + code) if code != default else ''
242             lg = code.split('_')
243             shorts.append(lg[0])
244             lang = {
245                 'hreflang': ('-'.join(lg)).lower(),
246                 'short': lg[0],
247                 'href': req.url_root[0:-1] + lg_path + uri,
248             }
249             langs.append(lang)
250         for lang in langs:
251             if shorts.count(lang['short']) == 1:
252                 lang['hreflang'] = lang['short']
253         return langs
254
255     @openerp.tools.ormcache(skiparg=4)
256     def _get_current_website_id(self, cr, uid, domain_name, context=None):
257         website_id = 1
258         if request:
259             ids = self.search(cr, uid, [('domain', '=', domain_name)], context=context)
260             if ids:
261                 website_id = ids[0]
262         return website_id
263
264     def get_current_website(self, cr, uid, context=None):
265         domain_name = request.httprequest.environ.get('HTTP_HOST', '').split(':')[0]
266         website_id = self._get_current_website_id(cr, uid, domain_name, context=context)
267         return self.browse(cr, uid, website_id, context=context)
268
269     def is_publisher(self, cr, uid, ids, context=None):
270         Access = self.pool['ir.model.access']
271         is_website_publisher = Access.check(cr, uid, 'ir.ui.view', 'write', False, context=context)
272         return is_website_publisher
273
274     def is_user(self, cr, uid, ids, context=None):
275         Access = self.pool['ir.model.access']
276         return Access.check(cr, uid, 'ir.ui.menu', 'read', False, context=context)
277
278     def get_template(self, cr, uid, ids, template, context=None):
279         if not isinstance(template, (int, long)) and '.' not in template:
280             template = 'website.%s' % template
281         View = self.pool['ir.ui.view']
282         view_id = View.get_view_id(cr, uid, template, context=context)
283         if not view_id:
284             raise NotFound
285         return View.browse(cr, uid, view_id, context=context)
286
287     def _render(self, cr, uid, ids, template, values=None, context=None):
288         # TODO: remove this. (just kept for backward api compatibility for saas-3)
289         return self.pool['ir.ui.view'].render(cr, uid, template, values=values, context=context)
290
291     def render(self, cr, uid, ids, template, values=None, status_code=None, context=None):
292         # TODO: remove this. (just kept for backward api compatibility for saas-3)
293         return request.render(template, values, uid=uid)
294
295     def pager(self, cr, uid, ids, url, total, page=1, step=30, scope=5, url_args=None, context=None):
296         # Compute Pager
297         page_count = int(math.ceil(float(total) / step))
298
299         page = max(1, min(int(page if str(page).isdigit() else 1), page_count))
300         scope -= 1
301
302         pmin = max(page - int(math.floor(scope/2)), 1)
303         pmax = min(pmin + scope, page_count)
304
305         if pmax - pmin < scope:
306             pmin = pmax - scope if pmax - scope > 0 else 1
307
308         def get_url(page):
309             _url = "%s/page/%s" % (url, page) if page > 1 else url
310             if url_args:
311                 _url = "%s?%s" % (_url, werkzeug.url_encode(url_args))
312             return _url
313
314         return {
315             "page_count": page_count,
316             "offset": (page - 1) * step,
317             "page": {
318                 'url': get_url(page),
319                 'num': page
320             },
321             "page_start": {
322                 'url': get_url(pmin),
323                 'num': pmin
324             },
325             "page_previous": {
326                 'url': get_url(max(pmin, page - 1)),
327                 'num': max(pmin, page - 1)
328             },
329             "page_next": {
330                 'url': get_url(min(pmax, page + 1)),
331                 'num': min(pmax, page + 1)
332             },
333             "page_end": {
334                 'url': get_url(pmax),
335                 'num': pmax
336             },
337             "pages": [
338                 {'url': get_url(page), 'num': page}
339                 for page in xrange(pmin, pmax+1)
340             ]
341         }
342
343     def rule_is_enumerable(self, rule):
344         """ Checks that it is possible to generate sensible GET queries for
345         a given rule (if the endpoint matches its own requirements)
346
347         :type rule: werkzeug.routing.Rule
348         :rtype: bool
349         """
350         endpoint = rule.endpoint
351         methods = rule.methods or ['GET']
352         converters = rule._converters.values()
353         if not ('GET' in methods
354             and endpoint.routing['type'] == 'http'
355             and endpoint.routing['auth'] in ('none', 'public')
356             and endpoint.routing.get('website', False)
357             and all(hasattr(converter, 'generate') for converter in converters)
358             and endpoint.routing.get('website')):
359             return False
360
361         # dont't list routes without argument having no default value or converter
362         spec = inspect.getargspec(endpoint.method.original_func)
363
364         # remove self and arguments having a default value
365         defaults_count = len(spec.defaults or [])
366         args = spec.args[1:(-defaults_count or None)]
367
368         # check that all args have a converter
369         return all( (arg in rule._converters) for arg in args)
370
371     def enumerate_pages(self, cr, uid, ids, query_string=None, context=None):
372         """ Available pages in the website/CMS. This is mostly used for links
373         generation and can be overridden by modules setting up new HTML
374         controllers for dynamic pages (e.g. blog).
375
376         By default, returns template views marked as pages.
377
378         :param str query_string: a (user-provided) string, fetches pages
379                                  matching the string
380         :returns: a list of mappings with two keys: ``name`` is the displayable
381                   name of the resource (page), ``url`` is the absolute URL
382                   of the same.
383         :rtype: list({name: str, url: str})
384         """
385         router = request.httprequest.app.get_db_router(request.db)
386         # Force enumeration to be performed as public user
387         url_list = []
388         for rule in router.iter_rules():
389             if not self.rule_is_enumerable(rule):
390                 continue
391
392             converters = rule._converters or {}
393             if query_string and not converters and (query_string not in rule.build([{}], append_unknown=False)[1]):
394                 continue
395             values = [{}]
396             convitems = converters.items()
397             # converters with a domain are processed after the other ones
398             gd = lambda x: hasattr(x[1], 'domain') and (x[1].domain <> '[]')
399             convitems.sort(lambda x, y: cmp(gd(x), gd(y)))
400             for (i,(name, converter)) in enumerate(convitems):
401                 newval = []
402                 for val in values:
403                     query = i==(len(convitems)-1) and query_string
404                     for v in converter.generate(request.cr, uid, query=query, args=val, context=context):
405                         newval.append( val.copy() )
406                         v[name] = v['loc']
407                         del v['loc']
408                         newval[-1].update(v)
409                 values = newval
410
411             for value in values:
412                 domain_part, url = rule.build(value, append_unknown=False)
413                 page = {'loc': url}
414                 for key,val in value.items():
415                     if key.startswith('__'):
416                         page[key[2:]] = val
417                 if url in ('/sitemap.xml',):
418                     continue
419                 if url in url_list:
420                     continue
421                 url_list.append(url)
422
423                 yield page
424
425     def search_pages(self, cr, uid, ids, needle=None, limit=None, context=None):
426         name = (needle or "").replace("/page/website.", "").replace("/page/", "")
427         res = []
428         for page in self.enumerate_pages(cr, uid, ids, query_string=name, context=context):
429             if needle in page['loc']:
430                 res.append(page)
431                 if len(res) == limit:
432                     break
433         return res
434
435     def kanban(self, cr, uid, ids, model, domain, column, template, step=None, scope=None, orderby=None, context=None):
436         step = step and int(step) or 10
437         scope = scope and int(scope) or 5
438         orderby = orderby or "name"
439
440         get_args = dict(request.httprequest.args or {})
441         model_obj = self.pool[model]
442         relation = model_obj._columns.get(column)._obj
443         relation_obj = self.pool[relation]
444
445         get_args.setdefault('kanban', "")
446         kanban = get_args.pop('kanban')
447         kanban_url = "?%s&kanban=" % werkzeug.url_encode(get_args)
448
449         pages = {}
450         for col in kanban.split(","):
451             if col:
452                 col = col.split("-")
453                 pages[int(col[0])] = int(col[1])
454
455         objects = []
456         for group in model_obj.read_group(cr, uid, domain, ["id", column], groupby=column):
457             obj = {}
458
459             # browse column
460             relation_id = group[column][0]
461             obj['column_id'] = relation_obj.browse(cr, uid, relation_id)
462
463             obj['kanban_url'] = kanban_url
464             for k, v in pages.items():
465                 if k != relation_id:
466                     obj['kanban_url'] += "%s-%s" % (k, v)
467
468             # pager
469             number = model_obj.search(cr, uid, group['__domain'], count=True)
470             obj['page_count'] = int(math.ceil(float(number) / step))
471             obj['page'] = pages.get(relation_id) or 1
472             if obj['page'] > obj['page_count']:
473                 obj['page'] = obj['page_count']
474             offset = (obj['page']-1) * step
475             obj['page_start'] = max(obj['page'] - int(math.floor((scope-1)/2)), 1)
476             obj['page_end'] = min(obj['page_start'] + (scope-1), obj['page_count'])
477
478             # view data
479             obj['domain'] = group['__domain']
480             obj['model'] = model
481             obj['step'] = step
482             obj['orderby'] = orderby
483
484             # browse objects
485             object_ids = model_obj.search(cr, uid, group['__domain'], limit=step, offset=offset, order=orderby)
486             obj['object_ids'] = model_obj.browse(cr, uid, object_ids)
487
488             objects.append(obj)
489
490         values = {
491             'objects': objects,
492             'range': range,
493             'template': template,
494         }
495         return request.website._render("website.kanban_contain", values)
496
497     def kanban_col(self, cr, uid, ids, model, domain, page, template, step, orderby, context=None):
498         html = ""
499         model_obj = self.pool[model]
500         domain = safe_eval(domain)
501         step = int(step)
502         offset = (int(page)-1) * step
503         object_ids = model_obj.search(cr, uid, domain, limit=step, offset=offset, order=orderby)
504         object_ids = model_obj.browse(cr, uid, object_ids)
505         for object_id in object_ids:
506             html += request.website._render(template, {'object_id': object_id})
507         return html
508
509     def _image_placeholder(self, response):
510         # file_open may return a StringIO. StringIO can be closed but are
511         # not context managers in Python 2 though that is fixed in 3
512         with contextlib.closing(openerp.tools.misc.file_open(
513                 os.path.join('web', 'static', 'src', 'img', 'placeholder.png'),
514                 mode='rb')) as f:
515             response.data = f.read()
516             return response.make_conditional(request.httprequest)
517
518     def _image(self, cr, uid, model, id, field, response, max_width=maxint, max_height=maxint, cache=None, context=None):
519         """ Fetches the requested field and ensures it does not go above
520         (max_width, max_height), resizing it if necessary.
521
522         Resizing is bypassed if the object provides a $field_big, which will
523         be interpreted as a pre-resized version of the base field.
524
525         If the record is not found or does not have the requested field,
526         returns a placeholder image via :meth:`~._image_placeholder`.
527
528         Sets and checks conditional response parameters:
529         * :mailheader:`ETag` is always set (and checked)
530         * :mailheader:`Last-Modified is set iif the record has a concurrency
531           field (``__last_update``)
532
533         The requested field is assumed to be base64-encoded image data in
534         all cases.
535         """
536         Model = self.pool[model]
537         id = int(id)
538
539         ids = Model.search(cr, uid,
540                            [('id', '=', id)], context=context)
541         if not ids and 'website_published' in Model._all_columns:
542             ids = Model.search(cr, openerp.SUPERUSER_ID,
543                                [('id', '=', id), ('website_published', '=', True)], context=context)
544         if not ids:
545             return self._image_placeholder(response)
546
547         concurrency = '__last_update'
548         [record] = Model.read(cr, openerp.SUPERUSER_ID, [id],
549                               [concurrency, field],
550                               context=context)
551
552         if concurrency in record:
553             server_format = openerp.tools.misc.DEFAULT_SERVER_DATETIME_FORMAT
554             try:
555                 response.last_modified = datetime.datetime.strptime(
556                     record[concurrency], server_format + '.%f')
557             except ValueError:
558                 # just in case we have a timestamp without microseconds
559                 response.last_modified = datetime.datetime.strptime(
560                     record[concurrency], server_format)
561
562         # Field does not exist on model or field set to False
563         if not record.get(field):
564             # FIXME: maybe a field which does not exist should be a 404?
565             return self._image_placeholder(response)
566
567         response.set_etag(hashlib.sha1(record[field]).hexdigest())
568         response.make_conditional(request.httprequest)
569
570         if cache:
571             response.cache_control.max_age = cache
572             response.expires = int(time.time() + cache)
573
574         # conditional request match
575         if response.status_code == 304:
576             return response
577
578         data = record[field].decode('base64')
579         image = Image.open(cStringIO.StringIO(data))
580         response.mimetype = Image.MIME[image.format]
581
582         filename = '%s_%s.%s' % (model.replace('.', '_'), id, str(image.format).lower())
583         response.headers['Content-Disposition'] = 'inline; filename="%s"' % filename
584
585         if (not max_width) and (not max_height):
586             response.data = data
587             return response
588
589         w, h = image.size
590         max_w = int(max_width) if max_width else maxint
591         max_h = int(max_height) if max_height else maxint
592
593         if w < max_w and h < max_h:
594             response.data = data
595         else:
596             size = (max_w, max_h)
597             img = image_resize_and_sharpen(image, size)
598             image_save_for_web(img, response.stream, format=image.format)
599             # invalidate content-length computed by make_conditional as
600             # writing to response.stream does not do it (as of werkzeug 0.9.3)
601             del response.headers['Content-Length']
602
603         return response
604
605     def image_url(self, cr, uid, record, field, size=None, context=None):
606         """Returns a local url that points to the image field of a given browse record."""
607         model = record._name
608         id = '%s_%s' % (record.id, hashlib.sha1(record.sudo().write_date).hexdigest()[0:7])
609         size = '' if size is None else '/%s' % size
610         return '/website/image/%s/%s/%s%s' % (model, id, field, size)
611
612
613 class website_menu(osv.osv):
614     _name = "website.menu"
615     _description = "Website Menu"
616     _columns = {
617         'name': fields.char('Menu', required=True, translate=True),
618         'url': fields.char('Url'),
619         'new_window': fields.boolean('New Window'),
620         'sequence': fields.integer('Sequence'),
621         # TODO: support multiwebsite once done for ir.ui.views
622         'website_id': fields.many2one('website', 'Website'),
623         'parent_id': fields.many2one('website.menu', 'Parent Menu', select=True, ondelete="cascade"),
624         'child_id': fields.one2many('website.menu', 'parent_id', string='Child Menus'),
625         'parent_left': fields.integer('Parent Left', select=True),
626         'parent_right': fields.integer('Parent Right', select=True),
627     }
628
629     def __defaults_sequence(self, cr, uid, context):
630         menu = self.search_read(cr, uid, [(1,"=",1)], ["sequence"], limit=1, order="sequence DESC", context=context)
631         return menu and menu[0]["sequence"] or 0
632
633     _defaults = {
634         'url': '',
635         'sequence': __defaults_sequence,
636         'new_window': False,
637     }
638     _parent_store = True
639     _parent_order = 'sequence'
640     _order = "sequence"
641
642     # would be better to take a menu_id as argument
643     def get_tree(self, cr, uid, website_id, context=None):
644         def make_tree(node):
645             menu_node = dict(
646                 id=node.id,
647                 name=node.name,
648                 url=node.url,
649                 new_window=node.new_window,
650                 sequence=node.sequence,
651                 parent_id=node.parent_id.id,
652                 children=[],
653             )
654             for child in node.child_id:
655                 menu_node['children'].append(make_tree(child))
656             return menu_node
657         menu = self.pool.get('website').browse(cr, uid, website_id, context=context).menu_id
658         return make_tree(menu)
659
660     def save(self, cr, uid, website_id, data, context=None):
661         def replace_id(old_id, new_id):
662             for menu in data['data']:
663                 if menu['id'] == old_id:
664                     menu['id'] = new_id
665                 if menu['parent_id'] == old_id:
666                     menu['parent_id'] = new_id
667         to_delete = data['to_delete']
668         if to_delete:
669             self.unlink(cr, uid, to_delete, context=context)
670         for menu in data['data']:
671             mid = menu['id']
672             if isinstance(mid, str):
673                 new_id = self.create(cr, uid, {'name': menu['name']}, context=context)
674                 replace_id(mid, new_id)
675         for menu in data['data']:
676             self.write(cr, uid, [menu['id']], menu, context=context)
677         return True
678
679 class ir_attachment(osv.osv):
680     _inherit = "ir.attachment"
681     def _website_url_get(self, cr, uid, ids, name, arg, context=None):
682         result = {}
683         for attach in self.browse(cr, uid, ids, context=context):
684             if attach.url:
685                 result[attach.id] = attach.url
686             else:
687                 result[attach.id] = self.pool['website'].image_url(cr, uid, attach, 'datas')
688         return result
689     def _datas_checksum(self, cr, uid, ids, name, arg, context=None):
690         return dict(
691             (attach['id'], self._compute_checksum(attach))
692             for attach in self.read(
693                 cr, uid, ids, ['res_model', 'res_id', 'type', 'datas'],
694                 context=context)
695         )
696
697     def _compute_checksum(self, attachment_dict):
698         if attachment_dict.get('res_model') == 'ir.ui.view'\
699                 and not attachment_dict.get('res_id') and not attachment_dict.get('url')\
700                 and attachment_dict.get('type', 'binary') == 'binary'\
701                 and attachment_dict.get('datas'):
702             return hashlib.new('sha1', attachment_dict['datas']).hexdigest()
703         return None
704
705     def _datas_big(self, cr, uid, ids, name, arg, context=None):
706         result = dict.fromkeys(ids, False)
707         if context and context.get('bin_size'):
708             return result
709
710         for record in self.browse(cr, uid, ids, context=context):
711             if not record.datas: continue
712             try:
713                 result[record.id] = openerp.tools.image_resize_image_big(record.datas)
714             except IOError: # apparently the error PIL.Image.open raises
715                 pass
716
717         return result
718
719     _columns = {
720         'datas_checksum': fields.function(_datas_checksum, size=40,
721               string="Datas checksum", type='char', store=True, select=True),
722         'website_url': fields.function(_website_url_get, string="Attachment URL", type='char'),
723         'datas_big': fields.function (_datas_big, type='binary', store=True,
724                                       string="Resized file content"),
725         'mimetype': fields.char('Mime Type', readonly=True),
726     }
727
728     def _add_mimetype_if_needed(self, values):
729         if values.get('datas_fname'):
730             values['mimetype'] = mimetypes.guess_type(values.get('datas_fname'))[0] or 'application/octet-stream'
731
732     def create(self, cr, uid, values, context=None):
733         chk = self._compute_checksum(values)
734         if chk:
735             match = self.search(cr, uid, [('datas_checksum', '=', chk)], context=context)
736             if match:
737                 return match[0]
738         self._add_mimetype_if_needed(values)
739         return super(ir_attachment, self).create(
740             cr, uid, values, context=context)
741
742     def write(self, cr, uid, ids, values, context=None):
743         self._add_mimetype_if_needed(values)
744         return super(ir_attachment, self).write(cr, uid, ids, values, context=context)
745
746     def try_remove(self, cr, uid, ids, context=None):
747         """ Removes a web-based image attachment if it is used by no view
748         (template)
749
750         Returns a dict mapping attachments which would not be removed (if any)
751         mapped to the views preventing their removal
752         """
753         Views = self.pool['ir.ui.view']
754         attachments_to_remove = []
755         # views blocking removal of the attachment
756         removal_blocked_by = {}
757
758         for attachment in self.browse(cr, uid, ids, context=context):
759             # in-document URLs are html-escaped, a straight search will not
760             # find them
761             url = escape(attachment.website_url)
762             ids = Views.search(cr, uid, ["|", ('arch', 'like', '"%s"' % url), ('arch', 'like', "'%s'" % url)], context=context)
763
764             if ids:
765                 removal_blocked_by[attachment.id] = Views.read(
766                     cr, uid, ids, ['name'], context=context)
767             else:
768                 attachments_to_remove.append(attachment.id)
769         if attachments_to_remove:
770             self.unlink(cr, uid, attachments_to_remove, context=context)
771         return removal_blocked_by
772
773 class res_partner(osv.osv):
774     _inherit = "res.partner"
775
776     def google_map_img(self, cr, uid, ids, zoom=8, width=298, height=298, context=None):
777         partner = self.browse(cr, uid, ids[0], context=context)
778         params = {
779             'center': '%s, %s %s, %s' % (partner.street or '', partner.city or '', partner.zip or '', partner.country_id and partner.country_id.name_get()[0][1] or ''),
780             'size': "%sx%s" % (height, width),
781             'zoom': zoom,
782             'sensor': 'false',
783         }
784         return urlplus('http://maps.googleapis.com/maps/api/staticmap' , params)
785
786     def google_map_link(self, cr, uid, ids, zoom=8, context=None):
787         partner = self.browse(cr, uid, ids[0], context=context)
788         params = {
789             'q': '%s, %s %s, %s' % (partner.street or '', partner.city  or '', partner.zip or '', partner.country_id and partner.country_id.name_get()[0][1] or ''),
790             'z': 10
791         }
792         return urlplus('https://maps.google.com/maps' , params)
793
794 class res_company(osv.osv):
795     _inherit = "res.company"
796     def google_map_img(self, cr, uid, ids, zoom=8, width=298, height=298, context=None):
797         partner = self.browse(cr, openerp.SUPERUSER_ID, ids[0], context=context).partner_id
798         return partner and partner.google_map_img(zoom, width, height, context=context) or None
799     def google_map_link(self, cr, uid, ids, zoom=8, context=None):
800         partner = self.browse(cr, openerp.SUPERUSER_ID, ids[0], context=context).partner_id
801         return partner and partner.google_map_link(zoom, context=context) or None
802
803 class base_language_install(osv.osv_memory):
804     _inherit = "base.language.install"
805     _columns = {
806         'website_ids': fields.many2many('website', string='Websites to translate'),
807     }
808
809     def default_get(self, cr, uid, fields, context=None):
810         if context is None:
811             context = {}
812         defaults = super(base_language_install, self).default_get(cr, uid, fields, context)
813         website_id = context.get('params', {}).get('website_id')
814         if website_id:
815             if 'website_ids' not in defaults:
816                 defaults['website_ids'] = []
817             defaults['website_ids'].append(website_id)
818         return defaults
819
820     def lang_install(self, cr, uid, ids, context=None):
821         if context is None:
822             context = {}
823         action = super(base_language_install, self).lang_install(cr, uid, ids, context)
824         language_obj = self.browse(cr, uid, ids)[0]
825         website_ids = [website.id for website in language_obj['website_ids']]
826         lang_id = self.pool['res.lang'].search(cr, uid, [('code', '=', language_obj['lang'])])
827         if website_ids and lang_id:
828             data = {'language_ids': [(4, lang_id[0])]}
829             self.pool['website'].write(cr, uid, website_ids, data)
830         params = context.get('params', {})
831         if 'url_return' in params:
832             return {
833                 'url': params['url_return'].replace('[lang]', language_obj['lang']),
834                 'type': 'ir.actions.act_url',
835                 'target': 'self'
836             }
837         return action
838
839 class website_seo_metadata(osv.Model):
840     _name = 'website.seo.metadata'
841     _description = 'SEO metadata'
842
843     _columns = {
844         'website_meta_title': fields.char("Website meta title", translate=True),
845         'website_meta_description': fields.text("Website meta description", translate=True),
846         'website_meta_keywords': fields.char("Website meta keywords", translate=True),
847     }
848
849 # vim:et: