1 # -*- coding: utf-8 -*-
12 import werkzeug.exceptions
14 import werkzeug.wrappers
15 # optional python-slugify import (https://github.com/un33k/python-slugify)
17 import slugify as slugify_lib
22 from openerp.osv import orm, osv, fields
23 from openerp.tools.safe_eval import safe_eval
24 from openerp.addons.web.http import request
26 logger = logging.getLogger(__name__)
28 def url_for(path_or_uri, lang=None):
29 if isinstance(path_or_uri, unicode):
30 path_or_uri = path_or_uri.encode('utf-8')
31 current_path = request.httprequest.path
32 if isinstance(current_path, unicode):
33 current_path = current_path.encode('utf-8')
34 location = path_or_uri.strip()
35 force_lang = lang is not None
36 url = urlparse.urlparse(location)
38 if request and not url.netloc and not url.scheme and (url.path or force_lang):
39 location = urlparse.urljoin(current_path, location)
41 lang = lang or request.context.get('lang')
42 langs = [lg[0] for lg in request.website.get_languages()]
44 if (len(langs) > 1 or force_lang) and is_multilang_url(location, langs):
45 ps = location.split('/')
47 # Replace the language only if we explicitly provide a language to url_for
50 # Remove the default language unless it's explicitly provided
51 elif ps[1] == request.website.default_lang_code:
53 # Insert the context language or the provided language
54 elif lang != request.website.default_lang_code or force_lang:
56 location = '/'.join(ps)
58 return location.decode('utf-8')
60 def is_multilang_url(local_url, langs=None):
62 langs = [lg[0] for lg in request.website.get_languages()]
63 spath = local_url.split('/')
64 # if a language is already in the path, remove it
67 local_url = '/'.join(spath)
69 # Try to match an endpoint in werkzeug's routing table
70 url = local_url.split('?')
72 query_string = url[1] if len(url) > 1 else None
73 router = request.httprequest.app.get_db_router(request.db).bind('')
74 func = router.match(path, query_args=query_string)[0]
75 return func.routing.get('multilang', False)
79 def slugify(s, max_length=None):
81 # There are 2 different libraries only python-slugify is supported
83 return slugify_lib.slugify(s, max_length=max_length)
86 spaceless = re.sub(r'\s+', '-', s)
87 specialless = re.sub(r'[^-_A-Za-z0-9]', '', spaceless)
88 return specialless[:max_length]
91 if isinstance(value, orm.browse_record):
92 # [(id, name)] = value.name_get()
93 id, name = value.id, value[value._rec_name]
95 # assume name_search result tuple
97 slugname = slugify(name or '')
100 return "%s-%d" % (slugname, id)
102 def urlplus(url, params):
103 return werkzeug.Href(url)(params or None)
105 class website(osv.osv):
106 def _get_menu_website(self, cr, uid, ids, context=None):
107 # IF a menu is changed, update all websites
108 return self.search(cr, uid, [], context=context)
110 def _get_menu(self, cr, uid, ids, name, arg, context=None):
111 root_domain = [('parent_id', '=', False)]
112 menus = self.pool.get('website.menu').search(cr, uid, root_domain, order='id', context=context)
113 menu = menus and menus[0] or False
114 return dict( map(lambda x: (x, menu), ids) )
116 _name = "website" # Avoid website.website convention for conciseness (for new api). Got a special authorization from xmo and rco
117 _description = "Website"
119 'name': fields.char('Domain'),
120 'company_id': fields.many2one('res.company', string="Company"),
121 'language_ids': fields.many2many('res.lang', 'website_lang_rel', 'website_id', 'lang_id', 'Languages'),
122 'default_lang_id': fields.many2one('res.lang', string="Default language"),
123 'default_lang_code': fields.related('default_lang_id', 'code', type="char", string="Default language code", store=True),
124 'social_twitter': fields.char('Twitter Account'),
125 'social_facebook': fields.char('Facebook Account'),
126 'social_github': fields.char('GitHub Account'),
127 'social_linkedin': fields.char('LinkedIn Account'),
128 'social_youtube': fields.char('Youtube Account'),
129 'social_googleplus': fields.char('Google+ Account'),
130 'google_analytics_key': fields.char('Google Analytics Key'),
131 'user_id': fields.many2one('res.users', string='Public User'),
132 'partner_id': fields.related('user_id','partner_id', type='many2one', relation='res.partner', string='Public Partner'),
133 'menu_id': fields.function(_get_menu, relation='website.menu', type='many2one', string='Main Menu',
135 'website.menu': (_get_menu_website, ['sequence','parent_id','website_id'], 10)
140 'company_id': lambda self,cr,uid,c: self.pool['ir.model.data'].xmlid_to_res_id(cr, openerp.SUPERUSER_ID, 'base.public_user'),
143 # cf. Wizard hack in website_views.xml
144 def noop(self, *args, **kwargs):
147 def write(self, cr, uid, ids, vals, context=None):
148 self._get_languages.clear_cache(self)
149 return super(website, self).write(cr, uid, ids, vals, context)
151 def new_page(self, cr, uid, name, template='website.default_page', ispage=True, context=None):
152 context = context or {}
153 imd = self.pool.get('ir.model.data')
154 view = self.pool.get('ir.ui.view')
155 template_module, template_name = template.split('.')
157 # completely arbitrary max_length
158 page_name = slugify(name, max_length=50)
159 page_xmlid = "%s.%s" % (template_module, page_name)
163 imd.get_object_reference(cr, uid, template_module, page_name)
166 _, template_id = imd.get_object_reference(cr, uid, template_module, template_name)
167 page_id = view.copy(cr, uid, template_id, context=context)
168 page = view.browse(cr, uid, page_id, context=context)
170 'arch': page.arch.replace(template, page_xmlid),
174 imd.create(cr, uid, {
176 'module': template_module,
177 'model': 'ir.ui.view',
183 def page_for_name(self, cr, uid, ids, name, module='website', context=None):
185 return '%s.%s' % (module, slugify(name, max_length=50))
187 def page_exists(self, cr, uid, ids, name, module='website', context=None):
189 return self.pool["ir.model.data"].get_object_reference(cr, uid, module, name)
193 @openerp.tools.ormcache(skiparg=3)
194 def _get_languages(self, cr, uid, id, context=None):
195 website = self.browse(cr, uid, id)
196 return [(lg.code, lg.name) for lg in website.language_ids]
198 def get_languages(self, cr, uid, ids, context=None):
199 return self._get_languages(cr, uid, ids[0])
201 def get_current_website(self, cr, uid, context=None):
202 # TODO: Select website, currently hard coded
203 return self.pool['website'].browse(cr, uid, 1, context=context)
205 def is_publisher(self, cr, uid, ids, context=None):
206 Access = self.pool['ir.model.access']
207 is_website_publisher = Access.check(cr, uid, 'ir.ui.view', 'write', False, context)
208 return is_website_publisher
210 def get_template(self, cr, uid, ids, template, context=None):
211 if isinstance(template, (int, long)):
214 if '.' not in template:
215 template = 'website.%s' % template
216 module, xmlid = template.split('.', 1)
217 model, view_id = request.registry["ir.model.data"].get_object_reference(cr, uid, module, xmlid)
218 return self.pool["ir.ui.view"].browse(cr, uid, view_id, context=context)
220 def _render(self, cr, uid, ids, template, values=None, context=None):
221 # TODO: remove this. (just kept for backward api compatibility for saas-3)
222 return self.pool['ir.ui.view'].render(cr, uid, template, values=values, context=context)
224 def render(self, cr, uid, ids, template, values=None, status_code=None, context=None):
225 # TODO: remove this. (just kept for backward api compatibility for saas-3)
226 return request.render(template, values, uid=uid)
228 def pager(self, cr, uid, ids, url, total, page=1, step=30, scope=5, url_args=None, context=None):
230 page_count = int(math.ceil(float(total) / step))
232 page = max(1, min(int(page), page_count))
235 pmin = max(page - int(math.floor(scope/2)), 1)
236 pmax = min(pmin + scope, page_count)
238 if pmax - pmin < scope:
239 pmin = pmax - scope if pmax - scope > 0 else 1
242 _url = "%s/page/%s" % (url, page) if page > 1 else url
244 _url = "%s?%s" % (_url, werkzeug.url_encode(url_args))
248 "page_count": page_count,
249 "offset": (page - 1) * step,
251 'url': get_url(page),
255 'url': get_url(pmin),
259 'url': get_url(max(pmin, page - 1)),
260 'num': max(pmin, page - 1)
263 'url': get_url(min(pmax, page + 1)),
264 'num': min(pmax, page + 1)
267 'url': get_url(pmax),
271 {'url': get_url(page), 'num': page}
272 for page in xrange(pmin, pmax+1)
276 def rule_is_enumerable(self, rule):
277 """ Checks that it is possible to generate sensible GET queries for
278 a given rule (if the endpoint matches its own requirements)
280 :type rule: werkzeug.routing.Rule
283 endpoint = rule.endpoint
284 methods = rule.methods or ['GET']
285 converters = rule._converters.values()
286 if not ('GET' in methods
287 and endpoint.routing['type'] == 'http'
288 and endpoint.routing['auth'] in ('none', 'public')
289 and endpoint.routing.get('website', False)
290 and all(hasattr(converter, 'generate') for converter in converters)
291 and endpoint.routing.get('website')):
294 # dont't list routes without argument having no default value or converter
295 spec = inspect.getargspec(endpoint.method.original_func)
297 # remove self and arguments having a default value
298 defaults_count = len(spec.defaults or [])
299 args = spec.args[1:(-defaults_count or None)]
301 # check that all args have a converter
302 return all( (arg in rule._converters) for arg in args)
304 def enumerate_pages(self, cr, uid, ids, query_string=None, context=None):
305 """ Available pages in the website/CMS. This is mostly used for links
306 generation and can be overridden by modules setting up new HTML
307 controllers for dynamic pages (e.g. blog).
309 By default, returns template views marked as pages.
311 :param str query_string: a (user-provided) string, fetches pages
313 :returns: a list of mappings with two keys: ``name`` is the displayable
314 name of the resource (page), ``url`` is the absolute URL
316 :rtype: list({name: str, url: str})
318 router = request.httprequest.app.get_db_router(request.db)
319 # Force enumeration to be performed as public user
320 uid = request.website.user_id.id
322 for rule in router.iter_rules():
323 if not self.rule_is_enumerable(rule):
326 converters = rule._converters or {}
328 convitems = converters.items()
329 # converters with a domain are processed after the other ones
330 gd = lambda x: hasattr(x[1], 'domain') and (x[1].domain <> '[]')
331 convitems.sort(lambda x, y: cmp(gd(x), gd(y)))
332 for (name, converter) in convitems:
335 for v in converter.generate(request.cr, uid, query=query_string, args=val, context=context):
336 newval.append( val.copy() )
343 domain_part, url = rule.build(value, append_unknown=False)
345 for key,val in value.items():
346 if key.startswith('__'):
348 if url in ('/sitemap.xml',):
353 if query_string and not self.page_matches(cr, uid, page, query_string, context=context):
357 def search_pages(self, cr, uid, ids, needle=None, limit=None, context=None):
358 return list(itertools.islice(
359 self.enumerate_pages(cr, uid, ids, query_string=needle, context=context),
362 def page_matches(self, cr, uid, page, needle, context=None):
363 """ Checks that a "page" matches a user-provide search string.
365 The default implementation attempts to perform a non-contiguous
366 substring match of the page's name.
368 :param page: {'name': str, 'url': str}
372 haystack = page['name'].lower()
374 needle = iter(needle.lower())
378 for char in haystack:
379 if char != n: continue
381 n = next(needle, end)
382 # found all characters of needle in haystack in order
388 def kanban(self, cr, uid, ids, model, domain, column, template, step=None, scope=None, orderby=None, context=None):
389 step = step and int(step) or 10
390 scope = scope and int(scope) or 5
391 orderby = orderby or "name"
393 get_args = dict(request.httprequest.args or {})
394 model_obj = self.pool[model]
395 relation = model_obj._columns.get(column)._obj
396 relation_obj = self.pool[relation]
398 get_args.setdefault('kanban', "")
399 kanban = get_args.pop('kanban')
400 kanban_url = "?%s&kanban=" % werkzeug.url_encode(get_args)
403 for col in kanban.split(","):
406 pages[int(col[0])] = int(col[1])
409 for group in model_obj.read_group(cr, uid, domain, ["id", column], groupby=column):
413 relation_id = group[column][0]
414 obj['column_id'] = relation_obj.browse(cr, uid, relation_id)
416 obj['kanban_url'] = kanban_url
417 for k, v in pages.items():
419 obj['kanban_url'] += "%s-%s" % (k, v)
422 number = model_obj.search(cr, uid, group['__domain'], count=True)
423 obj['page_count'] = int(math.ceil(float(number) / step))
424 obj['page'] = pages.get(relation_id) or 1
425 if obj['page'] > obj['page_count']:
426 obj['page'] = obj['page_count']
427 offset = (obj['page']-1) * step
428 obj['page_start'] = max(obj['page'] - int(math.floor((scope-1)/2)), 1)
429 obj['page_end'] = min(obj['page_start'] + (scope-1), obj['page_count'])
432 obj['domain'] = group['__domain']
435 obj['orderby'] = orderby
438 object_ids = model_obj.search(cr, uid, group['__domain'], limit=step, offset=offset, order=orderby)
439 obj['object_ids'] = model_obj.browse(cr, uid, object_ids)
446 'template': template,
448 return request.website._render("website.kanban_contain", values)
450 def kanban_col(self, cr, uid, ids, model, domain, page, template, step, orderby, context=None):
452 model_obj = self.pool[model]
453 domain = safe_eval(domain)
455 offset = (int(page)-1) * step
456 object_ids = model_obj.search(cr, uid, domain, limit=step, offset=offset, order=orderby)
457 object_ids = model_obj.browse(cr, uid, object_ids)
458 for object_id in object_ids:
459 html += request.website._render(template, {'object_id': object_id})
462 class website_menu(osv.osv):
463 _name = "website.menu"
464 _description = "Website Menu"
466 'name': fields.char('Menu', size=64, required=True, translate=True),
467 'url': fields.char('Url', translate=True),
468 'new_window': fields.boolean('New Window'),
469 'sequence': fields.integer('Sequence'),
470 # TODO: support multiwebsite once done for ir.ui.views
471 'website_id': fields.many2one('website', 'Website'),
472 'parent_id': fields.many2one('website.menu', 'Parent Menu', select=True, ondelete="cascade"),
473 'child_id': fields.one2many('website.menu', 'parent_id', string='Child Menus'),
474 'parent_left': fields.integer('Parent Left', select=True),
475 'parent_right': fields.integer('Parent Right', select=True),
478 def __defaults_sequence(self, cr, uid, context):
479 menu = self.search_read(cr, uid, [(1,"=",1)], ["sequence"], limit=1, order="sequence DESC", context=context)
480 return menu and menu[0]["sequence"] or 0
484 'sequence': __defaults_sequence,
488 _parent_order = 'sequence'
491 # would be better to take a menu_id as argument
492 def get_tree(self, cr, uid, website_id, context=None):
498 new_window=node.new_window,
499 sequence=node.sequence,
500 parent_id=node.parent_id.id,
503 for child in node.child_id:
504 menu_node['children'].append(make_tree(child))
506 menu = self.pool.get('website').browse(cr, uid, website_id, context=context).menu_id
507 return make_tree(menu)
509 def save(self, cr, uid, website_id, data, context=None):
510 def replace_id(old_id, new_id):
511 for menu in data['data']:
512 if menu['id'] == old_id:
514 if menu['parent_id'] == old_id:
515 menu['parent_id'] = new_id
516 to_delete = data['to_delete']
518 self.unlink(cr, uid, to_delete, context=context)
519 for menu in data['data']:
521 if isinstance(mid, str):
522 new_id = self.create(cr, uid, {'name': menu['name']}, context=context)
523 replace_id(mid, new_id)
524 for menu in data['data']:
525 self.write(cr, uid, [menu['id']], menu, context=context)
528 class ir_attachment(osv.osv):
529 _inherit = "ir.attachment"
530 def _website_url_get(self, cr, uid, ids, name, arg, context=None):
532 for attach in self.browse(cr, uid, ids, context=context):
534 result[attach.id] = attach.url
536 result[attach.id] = urlplus('/website/image', {
537 'model': 'ir.attachment',
542 def _datas_checksum(self, cr, uid, ids, name, arg, context=None):
544 (attach['id'], self._compute_checksum(attach))
545 for attach in self.read(
546 cr, uid, ids, ['res_model', 'res_id', 'type', 'datas'],
550 def _compute_checksum(self, attachment_dict):
551 if attachment_dict.get('res_model') == 'ir.ui.view'\
552 and not attachment_dict.get('res_id') and not attachment_dict.get('url')\
553 and attachment_dict.get('type', 'binary') == 'binary'\
554 and attachment_dict.get('datas'):
555 return hashlib.new('sha1', attachment_dict['datas']).hexdigest()
558 def _datas_big(self, cr, uid, ids, name, arg, context=None):
559 result = dict.fromkeys(ids, False)
560 if context and context.get('bin_size'):
563 for record in self.browse(cr, uid, ids, context=context):
564 if not record.datas: continue
566 result[record.id] = openerp.tools.image_resize_image_big(record.datas)
567 except IOError: # apparently the error PIL.Image.open raises
573 'datas_checksum': fields.function(_datas_checksum, size=40,
574 string="Datas checksum", type='char', store=True, select=True),
575 'website_url': fields.function(_website_url_get, string="Attachment URL", type='char'),
576 'datas_big': fields.function (_datas_big, type='binary', store=True,
577 string="Resized file content"),
578 'mimetype': fields.char('Mime Type', readonly=True),
581 def _add_mimetype_if_needed(self, values):
582 if values.get('datas_fname'):
583 values['mimetype'] = mimetypes.guess_type(values.get('datas_fname'))[0] or 'application/octet-stream'
585 def create(self, cr, uid, values, context=None):
586 chk = self._compute_checksum(values)
588 match = self.search(cr, uid, [('datas_checksum', '=', chk)], context=context)
591 self._add_mimetype_if_needed(values)
592 return super(ir_attachment, self).create(
593 cr, uid, values, context=context)
595 def write(self, cr, uid, ids, values, context=None):
596 self._add_mimetype_if_needed(values)
597 return super(ir_attachment, self).write(cr, uid, ids, values, context=context)
599 def try_remove(self, cr, uid, ids, context=None):
600 """ Removes a web-based image attachment if it is used by no view
603 Returns a dict mapping attachments which would not be removed (if any)
604 mapped to the views preventing their removal
606 Views = self.pool['ir.ui.view']
607 attachments_to_remove = []
608 # views blocking removal of the attachment
609 removal_blocked_by = {}
611 for attachment in self.browse(cr, uid, ids, context=context):
612 # in-document URLs are html-escaped, a straight search will not
614 url = werkzeug.utils.escape(attachment.website_url)
615 ids = Views.search(cr, uid, ["|", ('arch', 'like', '"%s"' % url), ('arch', 'like', "'%s'" % url)], context=context)
618 removal_blocked_by[attachment.id] = Views.read(
619 cr, uid, ids, ['name'], context=context)
621 attachments_to_remove.append(attachment.id)
622 if attachments_to_remove:
623 self.unlink(cr, uid, attachments_to_remove, context=context)
624 return removal_blocked_by
626 class res_partner(osv.osv):
627 _inherit = "res.partner"
629 def google_map_img(self, cr, uid, ids, zoom=8, width=298, height=298, context=None):
630 partner = self.browse(cr, uid, ids[0], context=context)
632 '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 ''),
633 'size': "%sx%s" % (height, width),
637 return urlplus('http://maps.googleapis.com/maps/api/staticmap' , params)
639 def google_map_link(self, cr, uid, ids, zoom=8, context=None):
640 partner = self.browse(cr, uid, ids[0], context=context)
642 '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 ''),
645 return urlplus('https://maps.google.com/maps' , params)
647 class res_company(osv.osv):
648 _inherit = "res.company"
649 def google_map_img(self, cr, uid, ids, zoom=8, width=298, height=298, context=None):
650 partner = self.browse(cr, openerp.SUPERUSER_ID, ids[0], context=context).partner_id
651 return partner and partner.google_map_img(zoom, width, height, context=context) or None
652 def google_map_link(self, cr, uid, ids, zoom=8, context=None):
653 partner = self.browse(cr, openerp.SUPERUSER_ID, ids[0], context=context).partner_id
654 return partner and partner.google_map_link(zoom, context=context) or None
656 class base_language_install(osv.osv_memory):
657 _inherit = "base.language.install"
659 'website_ids': fields.many2many('website', string='Websites to translate'),
662 def default_get(self, cr, uid, fields, context=None):
665 defaults = super(base_language_install, self).default_get(cr, uid, fields, context)
666 website_id = context.get('params', {}).get('website_id')
668 if 'website_ids' not in defaults:
669 defaults['website_ids'] = []
670 defaults['website_ids'].append(website_id)
673 def lang_install(self, cr, uid, ids, context=None):
676 action = super(base_language_install, self).lang_install(cr, uid, ids, context)
677 language_obj = self.browse(cr, uid, ids)[0]
678 website_ids = [website.id for website in language_obj['website_ids']]
679 lang_id = self.pool['res.lang'].search(cr, uid, [('code', '=', language_obj['lang'])])
680 if website_ids and lang_id:
681 data = {'language_ids': [(4, lang_id[0])]}
682 self.pool['website'].write(cr, uid, website_ids, data)
683 params = context.get('params', {})
684 if 'url_return' in params:
686 'url': params['url_return'].replace('[lang]', language_obj['lang']),
687 'type': 'ir.actions.act_url',
692 class website_seo_metadata(osv.Model):
693 _name = 'website.seo.metadata'
694 _description = 'SEO metadata'
697 'website_meta_title': fields.char("Website meta title", translate=True),
698 'website_meta_description': fields.text("Website meta description", translate=True),
699 'website_meta_keywords': fields.char("Website meta keywords", translate=True),