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)
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 _name = "website" # Avoid website.website convention for conciseness (for new api). Got a special authorization from xmo and rco
112 _description = "Website"
114 'name': fields.char('Domain'),
115 'company_id': fields.many2one('res.company', string="Company"),
116 'language_ids': fields.many2many('res.lang', 'website_lang_rel', 'website_id', 'lang_id', 'Languages'),
117 'default_lang_id': fields.many2one('res.lang', string="Default language"),
118 'default_lang_code': fields.related('default_lang_id', 'code', type="char", string="Default language code", store=True),
119 'social_twitter': fields.char('Twitter Account'),
120 'social_facebook': fields.char('Facebook Account'),
121 'social_github': fields.char('GitHub Account'),
122 'social_linkedin': fields.char('LinkedIn Account'),
123 'social_youtube': fields.char('Youtube Account'),
124 'social_googleplus': fields.char('Google+ Account'),
125 'google_analytics_key': fields.char('Google Analytics Key'),
126 'user_id': fields.many2one('res.users', string='Public User'),
127 'partner_id': fields.related('user_id','partner_id', type='many2one', relation='res.partner', string='Public Partner'),
128 'menu_id': fields.function(_get_menu, relation='website.menu', type='many2one', string='Main Menu',
130 'website.menu': (_get_menu_website, ['sequence','parent_id','website_id'], 10)
135 'company_id': lambda self,cr,uid,c: self.pool['ir.model.data'].xmlid_to_res_id(cr, openerp.SUPERUSER_ID, 'base.public_user'),
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 @openerp.tools.ormcache(skiparg=3)
189 def _get_languages(self, cr, uid, id, context=None):
190 website = self.browse(cr, uid, id)
191 return [(lg.code, lg.name) for lg in website.language_ids]
193 def get_languages(self, cr, uid, ids, context=None):
194 return self._get_languages(cr, uid, ids[0])
196 def get_current_website(self, cr, uid, context=None):
197 # TODO: Select website, currently hard coded
198 return self.pool['website'].browse(cr, uid, 1, context=context)
200 def preprocess_request(self, cr, uid, ids, request, context=None):
201 # TODO FP: is_website_publisher and editable in context should be removed
202 # for performance reasons (1 query per image to load) but also to be cleaner
203 # I propose to replace this by a group 'base.group_website_publisher' on the
204 # view that requires it.
205 Access = request.registry['ir.model.access']
206 is_website_publisher = Access.check(cr, uid, 'ir.ui.view', 'write', False, context)
208 lang = request.context['lang']
209 is_master_lang = lang == request.website.default_lang_code
211 request.redirect = lambda url: werkzeug.utils.redirect(url_for(url))
212 request.context.update(
213 editable=is_website_publisher,
214 translatable=not is_master_lang,
217 def get_template(self, cr, uid, ids, template, context=None):
218 if '.' not in template:
219 template = 'website.%s' % template
220 module, xmlid = template.split('.', 1)
221 model, view_id = request.registry["ir.model.data"].get_object_reference(cr, uid, module, xmlid)
222 return self.pool["ir.ui.view"].browse(cr, uid, view_id, context=context)
224 def _render(self, cr, uid, ids, template, values=None, context=None):
225 # TODO: remove this. (just kept for backward api compatibility for saas-3)
226 return self.pool['ir.ui.view'].render(cr, uid, template, values=values, context=context)
228 def render(self, cr, uid, ids, template, values=None, status_code=None, context=None):
229 # TODO: remove this. (just kept for backward api compatibility for saas-3)
230 return request.render(template, values, uid=uid)
232 def pager(self, cr, uid, ids, url, total, page=1, step=30, scope=5, url_args=None, context=None):
234 page_count = int(math.ceil(float(total) / step))
236 page = max(1, min(int(page), page_count))
239 pmin = max(page - int(math.floor(scope/2)), 1)
240 pmax = min(pmin + scope, page_count)
242 if pmax - pmin < scope:
243 pmin = pmax - scope if pmax - scope > 0 else 1
246 _url = "%spage/%s/" % (url, page) if page > 1 else url
248 _url = "%s?%s" % (_url, werkzeug.url_encode(url_args))
252 "page_count": page_count,
253 "offset": (page - 1) * step,
255 'url': get_url(page),
259 'url': get_url(pmin),
263 'url': get_url(max(pmin, page - 1)),
264 'num': max(pmin, page - 1)
267 'url': get_url(min(pmax, page + 1)),
268 'num': min(pmax, page + 1)
271 'url': get_url(pmax),
275 {'url': get_url(page), 'num': page}
276 for page in xrange(pmin, pmax+1)
280 def rule_is_enumerable(self, rule):
281 """ Checks that it is possible to generate sensible GET queries for
282 a given rule (if the endpoint matches its own requirements)
284 :type rule: werkzeug.routing.Rule
287 endpoint = rule.endpoint
288 methods = rule.methods or ['GET']
289 converters = rule._converters.values()
293 and endpoint.routing['type'] == 'http'
294 and endpoint.routing['auth'] in ('none', 'public')
295 and endpoint.routing.get('website', False)
296 # preclude combinatorial explosion by only allowing a single converter
297 and len(converters) <= 1
298 # ensure all converters on the rule are able to generate values for
300 and all(hasattr(converter, 'generate') for converter in converters)
301 ) and self.endpoint_is_enumerable(rule)
303 def endpoint_is_enumerable(self, rule):
304 """ Verifies that it's possible to generate a valid url for the rule's
307 :type rule: werkzeug.routing.Rule
310 spec = inspect.getargspec(rule.endpoint.method)
312 # if *args bail the fuck out, only dragons can live there
316 # remove all arguments with a default value from the list
317 defaults_count = len(spec.defaults or []) # spec.defaults can be None
318 # a[:-0] ~ a[:0] ~ [] -> replace defaults_count == 0 by None to get
320 args = spec.args[:(-defaults_count or None)]
322 # params with defaults were removed, leftover allowed are:
323 # * self (technically should be first-parameter-of-instance-method but whatever)
324 # * any parameter mapping to a converter
326 (arg == 'self' or arg in rule._converters)
329 def enumerate_pages(self, cr, uid, ids, query_string=None, context=None):
330 """ Available pages in the website/CMS. This is mostly used for links
331 generation and can be overridden by modules setting up new HTML
332 controllers for dynamic pages (e.g. blog).
334 By default, returns template views marked as pages.
336 :param str query_string: a (user-provided) string, fetches pages
338 :returns: a list of mappings with two keys: ``name`` is the displayable
339 name of the resource (page), ``url`` is the absolute URL
341 :rtype: list({name: str, url: str})
343 router = request.httprequest.app.get_db_router(request.db)
344 # Force enumeration to be performed as public user
345 uid = request.website.user_id.id
347 for rule in router.iter_rules():
348 if not self.rule_is_enumerable(rule):
351 converters = rule._converters
352 filtered = bool(converters)
354 # allow single converter as decided by fp, checked by
356 [(name, converter)] = converters.items()
357 converter_values = converter.generate(
358 request.cr, uid, query=query_string, context=context)
359 generated = ({k: v} for k, v in itertools.izip(
360 itertools.repeat(name), converter_values))
362 # force single iteration for literal urls
365 for values in generated:
366 domain_part, url = rule.build(values, append_unknown=False)
367 page = {'name': url, 'url': url}
371 if not filtered and query_string and not self.page_matches(cr, uid, page, query_string, context=context):
375 def search_pages(self, cr, uid, ids, needle=None, limit=None, context=None):
376 return list(itertools.islice(
377 self.enumerate_pages(cr, uid, ids, query_string=needle, context=context),
380 def page_matches(self, cr, uid, page, needle, context=None):
381 """ Checks that a "page" matches a user-provide search string.
383 The default implementation attempts to perform a non-contiguous
384 substring match of the page's name.
386 :param page: {'name': str, 'url': str}
390 haystack = page['name'].lower()
392 needle = iter(needle.lower())
396 for char in haystack:
397 if char != n: continue
399 n = next(needle, end)
400 # found all characters of needle in haystack in order
406 def kanban(self, cr, uid, ids, model, domain, column, template, step=None, scope=None, orderby=None, context=None):
407 step = step and int(step) or 10
408 scope = scope and int(scope) or 5
409 orderby = orderby or "name"
411 get_args = dict(request.httprequest.args or {})
412 model_obj = self.pool[model]
413 relation = model_obj._columns.get(column)._obj
414 relation_obj = self.pool[relation]
416 get_args.setdefault('kanban', "")
417 kanban = get_args.pop('kanban')
418 kanban_url = "?%s&kanban=" % werkzeug.url_encode(get_args)
421 for col in kanban.split(","):
424 pages[int(col[0])] = int(col[1])
427 for group in model_obj.read_group(cr, uid, domain, ["id", column], groupby=column):
431 relation_id = group[column][0]
432 obj['column_id'] = relation_obj.browse(cr, uid, relation_id)
434 obj['kanban_url'] = kanban_url
435 for k, v in pages.items():
437 obj['kanban_url'] += "%s-%s" % (k, v)
440 number = model_obj.search(cr, uid, group['__domain'], count=True)
441 obj['page_count'] = int(math.ceil(float(number) / step))
442 obj['page'] = pages.get(relation_id) or 1
443 if obj['page'] > obj['page_count']:
444 obj['page'] = obj['page_count']
445 offset = (obj['page']-1) * step
446 obj['page_start'] = max(obj['page'] - int(math.floor((scope-1)/2)), 1)
447 obj['page_end'] = min(obj['page_start'] + (scope-1), obj['page_count'])
450 obj['domain'] = group['__domain']
453 obj['orderby'] = orderby
456 object_ids = model_obj.search(cr, uid, group['__domain'], limit=step, offset=offset, order=orderby)
457 obj['object_ids'] = model_obj.browse(cr, uid, object_ids)
464 'template': template,
466 return request.website._render("website.kanban_contain", values)
468 def kanban_col(self, cr, uid, ids, model, domain, page, template, step, orderby, context=None):
470 model_obj = self.pool[model]
471 domain = safe_eval(domain)
473 offset = (int(page)-1) * step
474 object_ids = model_obj.search(cr, uid, domain, limit=step, offset=offset, order=orderby)
475 object_ids = model_obj.browse(cr, uid, object_ids)
476 for object_id in object_ids:
477 html += request.website._render(template, {'object_id': object_id})
480 class website_menu(osv.osv):
481 _name = "website.menu"
482 _description = "Website Menu"
484 'name': fields.char('Menu', size=64, required=True, translate=True),
485 'url': fields.char('Url', translate=True),
486 'new_window': fields.boolean('New Window'),
487 'sequence': fields.integer('Sequence'),
488 # TODO: support multiwebsite once done for ir.ui.views
489 'website_id': fields.many2one('website', 'Website'),
490 'parent_id': fields.many2one('website.menu', 'Parent Menu', select=True, ondelete="cascade"),
491 'child_id': fields.one2many('website.menu', 'parent_id', string='Child Menus'),
492 'parent_left': fields.integer('Parent Left', select=True),
493 'parent_right': fields.integer('Parent Right', select=True),
496 def __defaults_sequence(self, cr, uid, context):
497 menu = self.search_read(cr, uid, [(1,"=",1)], ["sequence"], limit=1, order="sequence DESC", context=context)
498 return menu and menu[0]["sequence"] or 0
502 'sequence': __defaults_sequence,
506 _parent_order = 'sequence'
509 # would be better to take a menu_id as argument
510 def get_tree(self, cr, uid, website_id, context=None):
516 new_window=node.new_window,
517 sequence=node.sequence,
518 parent_id=node.parent_id.id,
521 for child in node.child_id:
522 menu_node['children'].append(make_tree(child))
524 menu = self.pool.get('website').browse(cr, uid, website_id, context=context).menu_id
525 return make_tree(menu)
527 def save(self, cr, uid, website_id, data, context=None):
528 def replace_id(old_id, new_id):
529 for menu in data['data']:
530 if menu['id'] == old_id:
532 if menu['parent_id'] == old_id:
533 menu['parent_id'] = new_id
534 to_delete = data['to_delete']
536 self.unlink(cr, uid, to_delete, context=context)
537 for menu in data['data']:
539 if isinstance(mid, str):
540 new_id = self.create(cr, uid, {'name': menu['name']}, context=context)
541 replace_id(mid, new_id)
542 for menu in data['data']:
543 self.write(cr, uid, [menu['id']], menu, context=context)
546 class ir_attachment(osv.osv):
547 _inherit = "ir.attachment"
548 def _website_url_get(self, cr, uid, ids, name, arg, context=None):
550 for attach in self.browse(cr, uid, ids, context=context):
551 if attach.type == 'url':
552 result[attach.id] = attach.url
554 result[attach.id] = urlplus('/website/image', {
555 'model': 'ir.attachment',
562 def _datas_checksum(self, cr, uid, ids, name, arg, context=None):
564 (attach['id'], self._compute_checksum(attach))
565 for attach in self.read(
566 cr, uid, ids, ['res_model', 'res_id', 'type', 'datas'],
570 def _compute_checksum(self, attachment_dict):
571 if attachment_dict.get('res_model') == 'ir.ui.view'\
572 and not attachment_dict.get('res_id')\
573 and attachment_dict.get('type', 'binary') == 'binary'\
574 and attachment_dict.get('datas'):
575 return hashlib.new('sha1', attachment_dict['datas']).hexdigest()
579 'datas_checksum': fields.function(_datas_checksum, size=40,
580 string="Datas checksum", type='char', store=True, select=True),
581 'website_url': fields.function(_website_url_get, string="Attachment URL", type='char')
584 def create(self, cr, uid, values, context=None):
585 chk = self._compute_checksum(values)
587 match = self.search(cr, uid, [('datas_checksum', '=', chk)], context=context)
590 return super(ir_attachment, self).create(
591 cr, uid, values, context=context)
593 def try_remove(self, cr, uid, ids, context=None):
594 """ Removes a web-based image attachment if it is used by no view
597 Returns a dict mapping attachments which would not be removed (if any)
598 mapped to the views preventing their removal
600 Views = self.pool['ir.ui.view']
601 attachments_to_remove = []
602 # views blocking removal of the attachment
603 removal_blocked_by = {}
605 for attachment in self.browse(cr, uid, ids, context=context):
606 # in-document URLs are html-escaped, a straight search will not
608 url = werkzeug.utils.escape(attachment.website_url)
609 ids = Views.search(cr, uid, [('arch', 'like', url)], context=context)
612 removal_blocked_by[attachment.id] = Views.read(
613 cr, uid, ids, ['name'], context=context)
615 attachments_to_remove.append(attachment.id)
616 if attachments_to_remove:
617 self.unlink(cr, uid, attachments_to_remove, context=context)
618 return removal_blocked_by
620 class res_partner(osv.osv):
621 _inherit = "res.partner"
623 def google_map_img(self, cr, uid, ids, zoom=8, width=298, height=298, context=None):
624 partner = self.browse(cr, uid, ids[0], context=context)
626 '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 ''),
627 'size': "%sx%s" % (height, width),
631 return urlplus('http://maps.googleapis.com/maps/api/staticmap' , params)
633 def google_map_link(self, cr, uid, ids, zoom=8, context=None):
634 partner = self.browse(cr, uid, ids[0], context=context)
636 '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 ''),
639 return urlplus('https://maps.google.com/maps' , params)
641 class res_company(osv.osv):
642 _inherit = "res.company"
643 def google_map_img(self, cr, uid, ids, zoom=8, width=298, height=298, context=None):
644 partner = self.browse(cr, openerp.SUPERUSER_ID, ids[0], context=context).partner_id
645 return partner and partner.google_map_img(zoom, width, height, context=context) or None
646 def google_map_link(self, cr, uid, ids, zoom=8, context=None):
647 partner = self.browse(cr, openerp.SUPERUSER_ID, ids[0], context=context).partner_id
648 return partner and partner.google_map_link(zoom, context=context) or None
650 class base_language_install(osv.osv_memory):
651 _inherit = "base.language.install"
653 'website_ids': fields.many2many('website', string='Websites to translate'),
656 def default_get(self, cr, uid, fields, context=None):
659 defaults = super(base_language_install, self).default_get(cr, uid, fields, context)
660 website_id = context.get('params', {}).get('website_id')
662 if 'website_ids' not in defaults:
663 defaults['website_ids'] = []
664 defaults['website_ids'].append(website_id)
667 def lang_install(self, cr, uid, ids, context=None):
670 action = super(base_language_install, self).lang_install(cr, uid, ids, context)
671 language_obj = self.browse(cr, uid, ids)[0]
672 website_ids = [website.id for website in language_obj['website_ids']]
673 lang_id = self.pool['res.lang'].search(cr, uid, [('code', '=', language_obj['lang'])])
674 if website_ids and lang_id:
675 data = {'language_ids': [(4, lang_id[0])]}
676 self.pool['website'].write(cr, uid, website_ids, data)
677 params = context.get('params', {})
678 if 'url_return' in params:
680 'url': params['url_return'].replace('[lang]', language_obj['lang']),
681 'type': 'ir.actions.act_url',
686 class website_seo_metadata(osv.Model):
687 _name = 'website.seo.metadata'
688 _description = 'SEO metadata'
691 'website_meta_title': fields.char("Website meta title", translate=True),
692 'website_meta_description': fields.text("Website meta description", translate=True),
693 'website_meta_keywords': fields.char("Website meta keywords", translate=True),