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