1 # -*- coding: utf-8 -*-
11 import werkzeug.exceptions
13 import werkzeug.wrappers
14 # optional python-slugify import (https://github.com/un33k/python-slugify)
16 import slugify as slugify_lib
21 from openerp.osv import orm, osv, fields
22 from openerp.tools.safe_eval import safe_eval
23 from openerp.addons.web.http import request
25 logger = logging.getLogger(__name__)
27 def url_for(path_or_uri, lang=None):
28 if isinstance(path_or_uri, unicode):
29 path_or_uri = path_or_uri.encode('utf-8')
30 current_path = request.httprequest.path
31 if isinstance(current_path, unicode):
32 current_path = current_path.encode('utf-8')
33 location = path_or_uri.strip()
34 force_lang = lang is not None
35 url = urlparse.urlparse(location)
37 if request and not url.netloc and not url.scheme and (url.path or force_lang):
38 location = urlparse.urljoin(current_path, location)
40 lang = lang or request.context.get('lang')
41 langs = [lg[0] for lg in request.website.get_languages()]
43 if (len(langs) > 1 or force_lang) and is_multilang_url(location, langs):
44 ps = location.split('/')
46 # Replace the language only if we explicitly provide a language to url_for
49 # Remove the default language unless it's explicitly provided
50 elif ps[1] == request.website.default_lang_code:
52 # Insert the context language or the provided language
53 elif lang != request.website.default_lang_code or force_lang:
55 location = '/'.join(ps)
57 return location.decode('utf-8')
59 def is_multilang_url(path, langs=None):
61 langs = [lg[0] for lg in request.website.get_languages()]
62 spath = path.split('/')
63 # if a language is already in the path, remove it
66 path = '/'.join(spath)
68 router = request.httprequest.app.get_db_router(request.db).bind('')
69 func = router.match(path)[0]
70 return func.routing.get('multilang', False)
74 def slugify(s, max_length=None):
76 # There are 2 different libraries only python-slugify is supported
78 return slugify_lib.slugify(s, max_length=max_length)
81 spaceless = re.sub(r'\s+', '-', s)
82 specialless = re.sub(r'[^-_A-Za-z0-9]', '', spaceless)
83 return specialless[:max_length]
86 if isinstance(value, orm.browse_record):
87 # [(id, name)] = value.name_get()
88 id, name = value.id, value[value._rec_name]
90 # assume name_search result tuple
92 slugname = slugify(name or '')
95 return "%s-%d" % (slugname, id)
97 def urlplus(url, params):
98 return werkzeug.Href(url)(params or None)
100 class website(osv.osv):
101 def _get_menu_website(self, cr, uid, ids, context=None):
102 # IF a menu is changed, update all websites
103 return self.search(cr, uid, [], context=context)
105 def _get_menu(self, cr, uid, ids, name, arg, context=None):
106 root_domain = [('parent_id', '=', False)]
107 menus = self.pool.get('website.menu').search(cr, uid, root_domain, order='id', context=context)
108 menu = menus and menus[0] or False
109 return dict( map(lambda x: (x, menu), ids) )
111 def _get_public_user(self, cr, uid, ids, name='public_user', arg=(), context=None):
112 ref = self.get_public_user(cr, uid, context=context)
113 return dict( map(lambda x: (x, ref), ids) )
115 _name = "website" # Avoid website.website convention for conciseness (for new api). Got a special authorization from xmo and rco
116 _description = "Website"
118 'name': fields.char('Domain'),
119 'company_id': fields.many2one('res.company', string="Company"),
120 'language_ids': fields.many2many('res.lang', 'website_lang_rel', 'website_id', 'lang_id', 'Languages'),
121 'default_lang_id': fields.many2one('res.lang', string="Default language"),
122 'default_lang_code': fields.related('default_lang_id', 'code', type="char", string="Default language code", store=True),
123 'social_twitter': fields.char('Twitter Account'),
124 'social_facebook': fields.char('Facebook Account'),
125 'social_github': fields.char('GitHub Account'),
126 'social_linkedin': fields.char('LinkedIn Account'),
127 'social_youtube': fields.char('Youtube Account'),
128 'social_googleplus': fields.char('Google+ Account'),
129 'google_analytics_key': fields.char('Google Analytics Key'),
130 'user_id': fields.many2one('res.users', string='Public User'),
131 'public_user': fields.function(_get_public_user, relation='res.users', type='many2one', string='Public User'),
132 'menu_id': fields.function(_get_menu, relation='website.menu', type='many2one', string='Main Menu',
134 'website.menu': (_get_menu_website, ['sequence','parent_id','website_id'], 10)
138 # cf. Wizard hack in website_views.xml
139 def noop(self, *args, **kwargs):
142 def write(self, cr, uid, ids, vals, context=None):
143 self._get_languages.clear_cache(self)
144 return super(website, self).write(cr, uid, ids, vals, context)
146 def new_page(self, cr, uid, name, template='website.default_page', ispage=True, context=None):
147 context = context or {}
148 imd = self.pool.get('ir.model.data')
149 view = self.pool.get('ir.ui.view')
150 template_module, template_name = template.split('.')
152 # completely arbitrary max_length
153 page_name = slugify(name, max_length=50)
154 page_xmlid = "%s.%s" % (template_module, page_name)
158 imd.get_object_reference(cr, uid, template_module, page_name)
161 _, template_id = imd.get_object_reference(cr, uid, template_module, template_name)
162 page_id = view.copy(cr, uid, template_id, context=context)
163 page = view.browse(cr, uid, page_id, context=context)
165 'arch': page.arch.replace(template, page_xmlid),
169 imd.create(cr, uid, {
171 'module': template_module,
172 'model': 'ir.ui.view',
178 def page_for_name(self, cr, uid, ids, name, module='website', context=None):
180 return '%s.%s' % (module, slugify(name, max_length=50))
182 def page_exists(self, cr, uid, ids, name, module='website', context=None):
184 return self.pool["ir.model.data"].get_object_reference(cr, uid, module, name)
188 def get_public_user(self, cr, uid, context=None):
189 uid = openerp.SUPERUSER_ID
190 res = self.pool['ir.model.data'].get_object_reference(cr, uid, 'base', 'public_user')
191 return res and res[1] or False
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 preprocess_request(self, cr, uid, ids, request, context=None):
206 # TODO FP: is_website_publisher and editable in context should be removed
207 # for performance reasons (1 query per image to load) but also to be cleaner
208 # I propose to replace this by a group 'base.group_website_publisher' on the
209 # view that requires it.
210 Access = request.registry['ir.model.access']
211 is_website_publisher = Access.check(cr, uid, 'ir.ui.view', 'write', False, context)
213 lang = request.context['lang']
214 is_master_lang = lang == request.website.default_lang_code
216 request.redirect = lambda url: werkzeug.utils.redirect(url_for(url))
217 request.context.update(
218 editable=is_website_publisher,
219 translatable=not is_master_lang,
222 def get_template(self, cr, uid, ids, template, context=None):
223 if '.' not in template:
224 template = 'website.%s' % template
225 module, xmlid = template.split('.', 1)
226 model, view_id = request.registry["ir.model.data"].get_object_reference(cr, uid, module, xmlid)
227 return self.pool["ir.ui.view"].browse(cr, uid, view_id, context=context)
229 def _render(self, cr, uid, ids, template, values=None, context=None):
230 # TODO: remove this. (just kept for backward api compatibility for saas-3)
231 return self.pool['ir.ui.view'].render(cr, uid, template, values=values, context=context)
233 def render(self, cr, uid, ids, template, values=None, status_code=None, context=None):
234 # TODO: remove this. (just kept for backward api compatibility for saas-3)
235 return request.render(template, values, uid=uid)
237 def pager(self, cr, uid, ids, url, total, page=1, step=30, scope=5, url_args=None, context=None):
239 page_count = int(math.ceil(float(total) / step))
241 page = max(1, min(int(page), page_count))
244 pmin = max(page - int(math.floor(scope/2)), 1)
245 pmax = min(pmin + scope, page_count)
247 if pmax - pmin < scope:
248 pmin = pmax - scope if pmax - scope > 0 else 1
251 _url = "%spage/%s/" % (url, page) if page > 1 else url
253 _url = "%s?%s" % (_url, werkzeug.url_encode(url_args))
257 "page_count": page_count,
258 "offset": (page - 1) * step,
260 'url': get_url(page),
264 'url': get_url(pmin),
268 'url': get_url(max(pmin, page - 1)),
269 'num': max(pmin, page - 1)
272 'url': get_url(min(pmax, page + 1)),
273 'num': min(pmax, page + 1)
276 'url': get_url(pmax),
280 {'url': get_url(page), 'num': page}
281 for page in xrange(pmin, pmax+1)
285 def rule_is_enumerable(self, rule):
286 """ Checks that it is possible to generate sensible GET queries for
287 a given rule (if the endpoint matches its own requirements)
289 :type rule: werkzeug.routing.Rule
292 endpoint = rule.endpoint
293 methods = rule.methods or ['GET']
294 converters = rule._converters.values()
298 and endpoint.routing['type'] == 'http'
299 and endpoint.routing['auth'] in ('none', 'public')
300 and endpoint.routing.get('website', False)
301 # preclude combinatorial explosion by only allowing a single converter
302 and len(converters) <= 1
303 # ensure all converters on the rule are able to generate values for
305 and all(hasattr(converter, 'generate') for converter in converters)
306 ) and self.endpoint_is_enumerable(rule)
308 def endpoint_is_enumerable(self, rule):
309 """ Verifies that it's possible to generate a valid url for the rule's
312 :type rule: werkzeug.routing.Rule
315 spec = inspect.getargspec(rule.endpoint.method)
317 # if *args bail the fuck out, only dragons can live there
321 # remove all arguments with a default value from the list
322 defaults_count = len(spec.defaults or []) # spec.defaults can be None
323 # a[:-0] ~ a[:0] ~ [] -> replace defaults_count == 0 by None to get
325 args = spec.args[:(-defaults_count or None)]
327 # params with defaults were removed, leftover allowed are:
328 # * self (technically should be first-parameter-of-instance-method but whatever)
329 # * any parameter mapping to a converter
331 (arg == 'self' or arg in rule._converters)
334 def enumerate_pages(self, cr, uid, ids, query_string=None, context=None):
335 """ Available pages in the website/CMS. This is mostly used for links
336 generation and can be overridden by modules setting up new HTML
337 controllers for dynamic pages (e.g. blog).
339 By default, returns template views marked as pages.
341 :param str query_string: a (user-provided) string, fetches pages
343 :returns: a list of mappings with two keys: ``name`` is the displayable
344 name of the resource (page), ``url`` is the absolute URL
346 :rtype: list({name: str, url: str})
348 router = request.httprequest.app.get_db_router(request.db)
349 # Force enumeration to be performed as public user
350 uid = self.get_public_user(cr, uid, context=context)
352 for rule in router.iter_rules():
353 if not self.rule_is_enumerable(rule):
356 converters = rule._converters
357 filtered = bool(converters)
359 # allow single converter as decided by fp, checked by
361 [(name, converter)] = converters.items()
362 converter_values = converter.generate(
363 request.cr, uid, query=query_string, context=context)
364 generated = ({k: v} for k, v in itertools.izip(
365 itertools.repeat(name), converter_values))
367 # force single iteration for literal urls
370 for values in generated:
371 domain_part, url = rule.build(values, append_unknown=False)
372 page = {'name': url, 'url': url}
376 if not filtered and query_string and not self.page_matches(cr, uid, page, query_string, context=context):
380 def search_pages(self, cr, uid, ids, needle=None, limit=None, context=None):
381 return list(itertools.islice(
382 self.enumerate_pages(cr, uid, ids, query_string=needle, context=context),
385 def page_matches(self, cr, uid, page, needle, context=None):
386 """ Checks that a "page" matches a user-provide search string.
388 The default implementation attempts to perform a non-contiguous
389 substring match of the page's name.
391 :param page: {'name': str, 'url': str}
395 haystack = page['name'].lower()
397 needle = iter(needle.lower())
401 for char in haystack:
402 if char != n: continue
404 n = next(needle, end)
405 # found all characters of needle in haystack in order
411 def kanban(self, cr, uid, ids, model, domain, column, template, step=None, scope=None, orderby=None, context=None):
412 step = step and int(step) or 10
413 scope = scope and int(scope) or 5
414 orderby = orderby or "name"
416 get_args = dict(request.httprequest.args or {})
417 model_obj = self.pool[model]
418 relation = model_obj._columns.get(column)._obj
419 relation_obj = self.pool[relation]
421 get_args.setdefault('kanban', "")
422 kanban = get_args.pop('kanban')
423 kanban_url = "?%s&kanban=" % werkzeug.url_encode(get_args)
426 for col in kanban.split(","):
429 pages[int(col[0])] = int(col[1])
432 for group in model_obj.read_group(cr, uid, domain, ["id", column], groupby=column):
436 relation_id = group[column][0]
437 obj['column_id'] = relation_obj.browse(cr, uid, relation_id)
439 obj['kanban_url'] = kanban_url
440 for k, v in pages.items():
442 obj['kanban_url'] += "%s-%s" % (k, v)
445 number = model_obj.search(cr, uid, group['__domain'], count=True)
446 obj['page_count'] = int(math.ceil(float(number) / step))
447 obj['page'] = pages.get(relation_id) or 1
448 if obj['page'] > obj['page_count']:
449 obj['page'] = obj['page_count']
450 offset = (obj['page']-1) * step
451 obj['page_start'] = max(obj['page'] - int(math.floor((scope-1)/2)), 1)
452 obj['page_end'] = min(obj['page_start'] + (scope-1), obj['page_count'])
455 obj['domain'] = group['__domain']
458 obj['orderby'] = orderby
461 object_ids = model_obj.search(cr, uid, group['__domain'], limit=step, offset=offset, order=orderby)
462 obj['object_ids'] = model_obj.browse(cr, uid, object_ids)
469 'template': template,
471 return request.website._render("website.kanban_contain", values)
473 def kanban_col(self, cr, uid, ids, model, domain, page, template, step, orderby, context=None):
475 model_obj = self.pool[model]
476 domain = safe_eval(domain)
478 offset = (int(page)-1) * step
479 object_ids = model_obj.search(cr, uid, domain, limit=step, offset=offset, order=orderby)
480 object_ids = model_obj.browse(cr, uid, object_ids)
481 for object_id in object_ids:
482 html += request.website._render(template, {'object_id': object_id})
485 class website_menu(osv.osv):
486 _name = "website.menu"
487 _description = "Website Menu"
489 'name': fields.char('Menu', size=64, required=True, translate=True),
490 'url': fields.char('Url', translate=True),
491 'new_window': fields.boolean('New Window'),
492 'sequence': fields.integer('Sequence'),
493 # TODO: support multiwebsite once done for ir.ui.views
494 'website_id': fields.many2one('website', 'Website'),
495 'parent_id': fields.many2one('website.menu', 'Parent Menu', select=True, ondelete="cascade"),
496 'child_id': fields.one2many('website.menu', 'parent_id', string='Child Menus'),
497 'parent_left': fields.integer('Parent Left', select=True),
498 'parent_right': fields.integer('Parent Right', select=True),
501 def __defaults_sequence(self, cr, uid, context):
502 menu = self.search_read(cr, uid, [(1,"=",1)], ["sequence"], limit=1, order="sequence DESC", context=context)
503 return menu and menu[0]["sequence"] or 0
507 'sequence': __defaults_sequence,
511 _parent_order = 'sequence'
514 # would be better to take a menu_id as argument
515 def get_tree(self, cr, uid, website_id, context=None):
521 new_window=node.new_window,
522 sequence=node.sequence,
523 parent_id=node.parent_id.id,
526 for child in node.child_id:
527 menu_node['children'].append(make_tree(child))
529 menu = self.pool.get('website').browse(cr, uid, website_id, context=context).menu_id
530 return make_tree(menu)
532 def save(self, cr, uid, website_id, data, context=None):
533 def replace_id(old_id, new_id):
534 for menu in data['data']:
535 if menu['id'] == old_id:
537 if menu['parent_id'] == old_id:
538 menu['parent_id'] = new_id
539 to_delete = data['to_delete']
541 self.unlink(cr, uid, to_delete, context=context)
542 for menu in data['data']:
544 if isinstance(mid, str):
545 new_id = self.create(cr, uid, {'name': menu['name']}, context=context)
546 replace_id(mid, new_id)
547 for menu in data['data']:
548 self.write(cr, uid, [menu['id']], menu, context=context)
551 class ir_attachment(osv.osv):
552 _inherit = "ir.attachment"
553 def _website_url_get(self, cr, uid, ids, name, arg, context=None):
555 for attach in self.browse(cr, uid, ids, context=context):
556 if attach.type == 'url':
557 result[attach.id] = attach.url
559 result[attach.id] = urlplus('/website/image', {
560 'model': 'ir.attachment',
567 def _datas_checksum(self, cr, uid, ids, name, arg, context=None):
569 (attach['id'], self._compute_checksum(attach))
570 for attach in self.read(
571 cr, uid, ids, ['res_model', 'res_id', 'type', 'datas'],
575 def _compute_checksum(self, attachment_dict):
576 if attachment_dict.get('res_model') == 'ir.ui.view'\
577 and not attachment_dict.get('res_id') and not attachment_dict.get('url')\
578 and attachment_dict.get('type', 'binary') == 'binary'\
579 and attachment_dict.get('datas'):
580 return hashlib.new('sha1', attachment_dict['datas']).hexdigest()
583 def _datas_big(self, cr, uid, ids, name, arg, context=None):
584 result = dict.fromkeys(ids, False)
585 if context and context.get('bin_size'):
588 for record in self.browse(cr, uid, ids, context=context):
589 if not record.datas: continue
591 result[record.id] = openerp.tools.image_resize_image_big(record.datas)
592 except IOError: # apparently the error PIL.Image.open raises
598 'datas_checksum': fields.function(_datas_checksum, size=40,
599 string="Datas checksum", type='char', store=True, select=True),
600 'website_url': fields.function(_website_url_get, string="Attachment URL", type='char'),
601 'datas_big': fields.function (_datas_big, type='binary', store=True,
602 string="Resized file content"),
605 def create(self, cr, uid, values, context=None):
606 chk = self._compute_checksum(values)
608 match = self.search(cr, uid, [('datas_checksum', '=', chk)], context=context)
611 return super(ir_attachment, self).create(
612 cr, uid, 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', 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),