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(path, langs=None):
62 langs = [lg[0] for lg in request.website.get_languages()]
63 spath = path.split('/')
64 # if a language is already in the path, remove it
67 path = '/'.join(spath)
69 router = request.httprequest.app.get_db_router(request.db).bind('')
70 func = router.match(path)[0]
71 return func.routing.get('multilang', False)
75 def slugify(s, max_length=None):
77 # There are 2 different libraries only python-slugify is supported
79 return slugify_lib.slugify(s, max_length=max_length)
82 spaceless = re.sub(r'\s+', '-', s)
83 specialless = re.sub(r'[^-_A-Za-z0-9]', '', spaceless)
84 return specialless[:max_length]
87 if isinstance(value, orm.browse_record):
88 # [(id, name)] = value.name_get()
89 id, name = value.id, value[value._rec_name]
91 # assume name_search result tuple
93 slugname = slugify(name or '')
96 return "%s-%d" % (slugname, id)
98 def urlplus(url, params):
99 return werkzeug.Href(url)(params or None)
101 class website(osv.osv):
102 def _get_menu_website(self, cr, uid, ids, context=None):
103 # IF a menu is changed, update all websites
104 return self.search(cr, uid, [], context=context)
106 def _get_menu(self, cr, uid, ids, name, arg, context=None):
107 root_domain = [('parent_id', '=', False)]
108 menus = self.pool.get('website.menu').search(cr, uid, root_domain, order='id', context=context)
109 menu = menus and menus[0] or False
110 return dict( map(lambda x: (x, menu), ids) )
112 def _get_public_user(self, cr, uid, ids, name='public_user', arg=(), context=None):
113 ref = self.get_public_user(cr, uid, context=context)
114 return dict( map(lambda x: (x, ref), 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 'public_user': fields.function(_get_public_user, relation='res.users', type='many2one', string='Public User'),
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)
139 # cf. Wizard hack in website_views.xml
140 def noop(self, *args, **kwargs):
143 def write(self, cr, uid, ids, vals, context=None):
144 self._get_languages.clear_cache(self)
145 return super(website, self).write(cr, uid, ids, vals, context)
147 def new_page(self, cr, uid, name, template='website.default_page', ispage=True, context=None):
148 context = context or {}
149 imd = self.pool.get('ir.model.data')
150 view = self.pool.get('ir.ui.view')
151 template_module, template_name = template.split('.')
153 # completely arbitrary max_length
154 page_name = slugify(name, max_length=50)
155 page_xmlid = "%s.%s" % (template_module, page_name)
159 imd.get_object_reference(cr, uid, template_module, page_name)
162 _, template_id = imd.get_object_reference(cr, uid, template_module, template_name)
163 page_id = view.copy(cr, uid, template_id, context=context)
164 page = view.browse(cr, uid, page_id, context=context)
166 'arch': page.arch.replace(template, page_xmlid),
170 imd.create(cr, uid, {
172 'module': template_module,
173 'model': 'ir.ui.view',
179 def page_for_name(self, cr, uid, ids, name, module='website', context=None):
181 return '%s.%s' % (module, slugify(name, max_length=50))
183 def page_exists(self, cr, uid, ids, name, module='website', context=None):
185 return self.pool["ir.model.data"].get_object_reference(cr, uid, module, name)
189 def get_public_user(self, cr, uid, context=None):
190 uid = openerp.SUPERUSER_ID
191 res = self.pool['ir.model.data'].get_object_reference(cr, uid, 'base', 'public_user')
192 return res and res[1] or False
194 @openerp.tools.ormcache(skiparg=3)
195 def _get_languages(self, cr, uid, id, context=None):
196 website = self.browse(cr, uid, id)
197 return [(lg.code, lg.name) for lg in website.language_ids]
199 def get_languages(self, cr, uid, ids, context=None):
200 return self._get_languages(cr, uid, ids[0])
202 def get_current_website(self, cr, uid, context=None):
203 # TODO: Select website, currently hard coded
204 return self.pool['website'].browse(cr, uid, 1, context=context)
206 def preprocess_request(self, cr, uid, ids, request, context=None):
207 # TODO FP: is_website_publisher and editable in context should be removed
208 # for performance reasons (1 query per image to load) but also to be cleaner
209 # I propose to replace this by a group 'base.group_website_publisher' on the
210 # view that requires it.
211 Access = request.registry['ir.model.access']
212 is_website_publisher = Access.check(cr, uid, 'ir.ui.view', 'write', False, context)
214 lang = request.context['lang']
215 is_master_lang = lang == request.website.default_lang_code
217 request.redirect = lambda url: werkzeug.utils.redirect(url_for(url))
218 request.context.update(
219 editable=is_website_publisher,
220 translatable=not is_master_lang,
223 def get_template(self, cr, uid, ids, template, context=None):
224 if isinstance(template, (int, long)):
227 if '.' not in template:
228 template = 'website.%s' % template
229 module, xmlid = template.split('.', 1)
230 model, view_id = request.registry["ir.model.data"].get_object_reference(cr, uid, module, xmlid)
231 return self.pool["ir.ui.view"].browse(cr, uid, view_id, context=context)
233 def _render(self, cr, uid, ids, template, values=None, context=None):
234 # TODO: remove this. (just kept for backward api compatibility for saas-3)
235 return self.pool['ir.ui.view'].render(cr, uid, template, values=values, context=context)
237 def render(self, cr, uid, ids, template, values=None, status_code=None, context=None):
238 # TODO: remove this. (just kept for backward api compatibility for saas-3)
239 return request.render(template, values, uid=uid)
241 def pager(self, cr, uid, ids, url, total, page=1, step=30, scope=5, url_args=None, context=None):
243 page_count = int(math.ceil(float(total) / step))
245 page = max(1, min(int(page), page_count))
248 pmin = max(page - int(math.floor(scope/2)), 1)
249 pmax = min(pmin + scope, page_count)
251 if pmax - pmin < scope:
252 pmin = pmax - scope if pmax - scope > 0 else 1
255 _url = "%s/page/%s" % (url, page) if page > 1 else url
257 _url = "%s?%s" % (_url, werkzeug.url_encode(url_args))
261 "page_count": page_count,
262 "offset": (page - 1) * step,
264 'url': get_url(page),
268 'url': get_url(pmin),
272 'url': get_url(max(pmin, page - 1)),
273 'num': max(pmin, page - 1)
276 'url': get_url(min(pmax, page + 1)),
277 'num': min(pmax, page + 1)
280 'url': get_url(pmax),
284 {'url': get_url(page), 'num': page}
285 for page in xrange(pmin, pmax+1)
289 def rule_is_enumerable(self, rule):
290 """ Checks that it is possible to generate sensible GET queries for
291 a given rule (if the endpoint matches its own requirements)
293 :type rule: werkzeug.routing.Rule
296 endpoint = rule.endpoint
297 methods = rule.methods or ['GET']
298 converters = rule._converters.values()
299 if not ('GET' in methods
300 and endpoint.routing['type'] == 'http'
301 and endpoint.routing['auth'] in ('none', 'public')
302 and endpoint.routing.get('website', False)
303 and all(hasattr(converter, 'generate') for converter in converters)
304 and endpoint.routing.get('website')):
307 # dont't list routes without argument having no default value or converter
308 spec = inspect.getargspec(endpoint.method.original_func)
310 # remove self and arguments having a default value
311 defaults_count = len(spec.defaults or [])
312 args = spec.args[1:(-defaults_count or None)]
314 # check that all args have a converter
315 return all( (arg in rule._converters) for arg in args)
317 def enumerate_pages(self, cr, uid, ids, query_string=None, context=None):
318 """ Available pages in the website/CMS. This is mostly used for links
319 generation and can be overridden by modules setting up new HTML
320 controllers for dynamic pages (e.g. blog).
322 By default, returns template views marked as pages.
324 :param str query_string: a (user-provided) string, fetches pages
326 :returns: a list of mappings with two keys: ``name`` is the displayable
327 name of the resource (page), ``url`` is the absolute URL
329 :rtype: list({name: str, url: str})
331 router = request.httprequest.app.get_db_router(request.db)
332 # Force enumeration to be performed as public user
333 uid = self.get_public_user(cr, uid, context=context)
335 for rule in router.iter_rules():
336 if not self.rule_is_enumerable(rule):
339 converters = rule._converters or {}
341 convitems = converters.items()
342 # converters with a domain are processed after the other ones
343 gd = lambda x: hasattr(x[1], 'domain') and (x[1].domain <> '[]')
344 convitems.sort(lambda x, y: cmp(gd(x), gd(y)))
345 for (name, converter) in convitems:
348 for v in converter.generate(request.cr, uid, query=query_string, args=val, context=context):
349 newval.append( val.copy() )
356 domain_part, url = rule.build(value, append_unknown=False)
358 for key,val in value.items():
359 if key.startswith('__'):
361 if url in ('/sitemap.xml',):
366 if query_string and not self.page_matches(cr, uid, page, query_string, context=context):
370 def search_pages(self, cr, uid, ids, needle=None, limit=None, context=None):
371 return list(itertools.islice(
372 self.enumerate_pages(cr, uid, ids, query_string=needle, context=context),
375 def page_matches(self, cr, uid, page, needle, context=None):
376 """ Checks that a "page" matches a user-provide search string.
378 The default implementation attempts to perform a non-contiguous
379 substring match of the page's name.
381 :param page: {'name': str, 'url': str}
385 haystack = page['name'].lower()
387 needle = iter(needle.lower())
391 for char in haystack:
392 if char != n: continue
394 n = next(needle, end)
395 # found all characters of needle in haystack in order
401 def kanban(self, cr, uid, ids, model, domain, column, template, step=None, scope=None, orderby=None, context=None):
402 step = step and int(step) or 10
403 scope = scope and int(scope) or 5
404 orderby = orderby or "name"
406 get_args = dict(request.httprequest.args or {})
407 model_obj = self.pool[model]
408 relation = model_obj._columns.get(column)._obj
409 relation_obj = self.pool[relation]
411 get_args.setdefault('kanban', "")
412 kanban = get_args.pop('kanban')
413 kanban_url = "?%s&kanban=" % werkzeug.url_encode(get_args)
416 for col in kanban.split(","):
419 pages[int(col[0])] = int(col[1])
422 for group in model_obj.read_group(cr, uid, domain, ["id", column], groupby=column):
426 relation_id = group[column][0]
427 obj['column_id'] = relation_obj.browse(cr, uid, relation_id)
429 obj['kanban_url'] = kanban_url
430 for k, v in pages.items():
432 obj['kanban_url'] += "%s-%s" % (k, v)
435 number = model_obj.search(cr, uid, group['__domain'], count=True)
436 obj['page_count'] = int(math.ceil(float(number) / step))
437 obj['page'] = pages.get(relation_id) or 1
438 if obj['page'] > obj['page_count']:
439 obj['page'] = obj['page_count']
440 offset = (obj['page']-1) * step
441 obj['page_start'] = max(obj['page'] - int(math.floor((scope-1)/2)), 1)
442 obj['page_end'] = min(obj['page_start'] + (scope-1), obj['page_count'])
445 obj['domain'] = group['__domain']
448 obj['orderby'] = orderby
451 object_ids = model_obj.search(cr, uid, group['__domain'], limit=step, offset=offset, order=orderby)
452 obj['object_ids'] = model_obj.browse(cr, uid, object_ids)
459 'template': template,
461 return request.website._render("website.kanban_contain", values)
463 def kanban_col(self, cr, uid, ids, model, domain, page, template, step, orderby, context=None):
465 model_obj = self.pool[model]
466 domain = safe_eval(domain)
468 offset = (int(page)-1) * step
469 object_ids = model_obj.search(cr, uid, domain, limit=step, offset=offset, order=orderby)
470 object_ids = model_obj.browse(cr, uid, object_ids)
471 for object_id in object_ids:
472 html += request.website._render(template, {'object_id': object_id})
475 class website_menu(osv.osv):
476 _name = "website.menu"
477 _description = "Website Menu"
479 'name': fields.char('Menu', size=64, required=True, translate=True),
480 'url': fields.char('Url', translate=True),
481 'new_window': fields.boolean('New Window'),
482 'sequence': fields.integer('Sequence'),
483 # TODO: support multiwebsite once done for ir.ui.views
484 'website_id': fields.many2one('website', 'Website'),
485 'parent_id': fields.many2one('website.menu', 'Parent Menu', select=True, ondelete="cascade"),
486 'child_id': fields.one2many('website.menu', 'parent_id', string='Child Menus'),
487 'parent_left': fields.integer('Parent Left', select=True),
488 'parent_right': fields.integer('Parent Right', select=True),
491 def __defaults_sequence(self, cr, uid, context):
492 menu = self.search_read(cr, uid, [(1,"=",1)], ["sequence"], limit=1, order="sequence DESC", context=context)
493 return menu and menu[0]["sequence"] or 0
497 'sequence': __defaults_sequence,
501 _parent_order = 'sequence'
504 # would be better to take a menu_id as argument
505 def get_tree(self, cr, uid, website_id, context=None):
511 new_window=node.new_window,
512 sequence=node.sequence,
513 parent_id=node.parent_id.id,
516 for child in node.child_id:
517 menu_node['children'].append(make_tree(child))
519 menu = self.pool.get('website').browse(cr, uid, website_id, context=context).menu_id
520 return make_tree(menu)
522 def save(self, cr, uid, website_id, data, context=None):
523 def replace_id(old_id, new_id):
524 for menu in data['data']:
525 if menu['id'] == old_id:
527 if menu['parent_id'] == old_id:
528 menu['parent_id'] = new_id
529 to_delete = data['to_delete']
531 self.unlink(cr, uid, to_delete, context=context)
532 for menu in data['data']:
534 if isinstance(mid, str):
535 new_id = self.create(cr, uid, {'name': menu['name']}, context=context)
536 replace_id(mid, new_id)
537 for menu in data['data']:
538 self.write(cr, uid, [menu['id']], menu, context=context)
541 class ir_attachment(osv.osv):
542 _inherit = "ir.attachment"
543 def _website_url_get(self, cr, uid, ids, name, arg, context=None):
545 for attach in self.browse(cr, uid, ids, context=context):
547 result[attach.id] = attach.url
549 result[attach.id] = urlplus('/website/image', {
550 'model': 'ir.attachment',
557 def _datas_checksum(self, cr, uid, ids, name, arg, context=None):
559 (attach['id'], self._compute_checksum(attach))
560 for attach in self.read(
561 cr, uid, ids, ['res_model', 'res_id', 'type', 'datas'],
565 def _compute_checksum(self, attachment_dict):
566 if attachment_dict.get('res_model') == 'ir.ui.view'\
567 and not attachment_dict.get('res_id') and not attachment_dict.get('url')\
568 and attachment_dict.get('type', 'binary') == 'binary'\
569 and attachment_dict.get('datas'):
570 return hashlib.new('sha1', attachment_dict['datas']).hexdigest()
573 def _datas_big(self, cr, uid, ids, name, arg, context=None):
574 result = dict.fromkeys(ids, False)
575 if context and context.get('bin_size'):
578 for record in self.browse(cr, uid, ids, context=context):
579 if not record.datas: continue
581 result[record.id] = openerp.tools.image_resize_image_big(record.datas)
582 except IOError: # apparently the error PIL.Image.open raises
588 'datas_checksum': fields.function(_datas_checksum, size=40,
589 string="Datas checksum", type='char', store=True, select=True),
590 'website_url': fields.function(_website_url_get, string="Attachment URL", type='char'),
591 'datas_big': fields.function (_datas_big, type='binary', store=True,
592 string="Resized file content"),
593 'mimetype': fields.char('Mime Type', readonly=True),
596 def _add_mimetype_if_needed(self, values):
597 if values.get('datas_fname'):
598 values['mimetype'] = mimetypes.guess_type(values.get('datas_fname'))[0] or 'application/octet-stream'
600 def create(self, cr, uid, values, context=None):
601 chk = self._compute_checksum(values)
603 match = self.search(cr, uid, [('datas_checksum', '=', chk)], context=context)
606 self._add_mimetype_if_needed(values)
607 return super(ir_attachment, self).create(
608 cr, uid, values, context=context)
610 def write(self, cr, uid, ids, values, context=None):
611 self._add_mimetype_if_needed(values)
612 return super(ir_attachment, self).write(cr, uid, ids, values, context=context)
614 def try_remove(self, cr, uid, ids, context=None):
615 """ Removes a web-based image attachment if it is used by no view
618 Returns a dict mapping attachments which would not be removed (if any)
619 mapped to the views preventing their removal
621 Views = self.pool['ir.ui.view']
622 attachments_to_remove = []
623 # views blocking removal of the attachment
624 removal_blocked_by = {}
626 for attachment in self.browse(cr, uid, ids, context=context):
627 # in-document URLs are html-escaped, a straight search will not
629 url = werkzeug.utils.escape(attachment.website_url)
630 ids = Views.search(cr, uid, ["|", ('arch', 'like', '"%s"' % url), ('arch', 'like', "'%s'" % url)], context=context)
633 removal_blocked_by[attachment.id] = Views.read(
634 cr, uid, ids, ['name'], context=context)
636 attachments_to_remove.append(attachment.id)
637 if attachments_to_remove:
638 self.unlink(cr, uid, attachments_to_remove, context=context)
639 return removal_blocked_by
641 class res_partner(osv.osv):
642 _inherit = "res.partner"
644 def google_map_img(self, cr, uid, ids, zoom=8, width=298, height=298, context=None):
645 partner = self.browse(cr, uid, ids[0], context=context)
647 '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 ''),
648 'size': "%sx%s" % (height, width),
652 return urlplus('http://maps.googleapis.com/maps/api/staticmap' , params)
654 def google_map_link(self, cr, uid, ids, zoom=8, context=None):
655 partner = self.browse(cr, uid, ids[0], context=context)
657 '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 ''),
660 return urlplus('https://maps.google.com/maps' , params)
662 class res_company(osv.osv):
663 _inherit = "res.company"
664 def google_map_img(self, cr, uid, ids, zoom=8, width=298, height=298, context=None):
665 partner = self.browse(cr, openerp.SUPERUSER_ID, ids[0], context=context).partner_id
666 return partner and partner.google_map_img(zoom, width, height, context=context) or None
667 def google_map_link(self, cr, uid, ids, zoom=8, context=None):
668 partner = self.browse(cr, openerp.SUPERUSER_ID, ids[0], context=context).partner_id
669 return partner and partner.google_map_link(zoom, context=context) or None
671 class base_language_install(osv.osv_memory):
672 _inherit = "base.language.install"
674 'website_ids': fields.many2many('website', string='Websites to translate'),
677 def default_get(self, cr, uid, fields, context=None):
680 defaults = super(base_language_install, self).default_get(cr, uid, fields, context)
681 website_id = context.get('params', {}).get('website_id')
683 if 'website_ids' not in defaults:
684 defaults['website_ids'] = []
685 defaults['website_ids'].append(website_id)
688 def lang_install(self, cr, uid, ids, context=None):
691 action = super(base_language_install, self).lang_install(cr, uid, ids, context)
692 language_obj = self.browse(cr, uid, ids)[0]
693 website_ids = [website.id for website in language_obj['website_ids']]
694 lang_id = self.pool['res.lang'].search(cr, uid, [('code', '=', language_obj['lang'])])
695 if website_ids and lang_id:
696 data = {'language_ids': [(4, lang_id[0])]}
697 self.pool['website'].write(cr, uid, website_ids, data)
698 params = context.get('params', {})
699 if 'url_return' in params:
701 'url': params['url_return'].replace('[lang]', language_obj['lang']),
702 'type': 'ir.actions.act_url',
707 class website_seo_metadata(osv.Model):
708 _name = 'website.seo.metadata'
709 _description = 'SEO metadata'
712 'website_meta_title': fields.char("Website meta title", translate=True),
713 'website_meta_description': fields.text("Website meta description", translate=True),
714 'website_meta_keywords': fields.char("Website meta keywords", translate=True),