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()
289 and endpoint.routing['type'] == 'http'
290 and endpoint.routing['auth'] in ('none', 'public')
291 and endpoint.routing.get('website', False)
292 # preclude combinatorial explosion by only allowing a single converter
293 and len(converters) <= 1
294 # ensure all converters on the rule are able to generate values for
296 and all(hasattr(converter, 'generate') for converter in converters)
297 ) and self.endpoint_is_enumerable(rule)
299 def endpoint_is_enumerable(self, rule):
300 """ Verifies that it's possible to generate a valid url for the rule's
303 :type rule: werkzeug.routing.Rule
306 spec = inspect.getargspec(rule.endpoint.method)
308 # if *args bail the fuck out, only dragons can live there
312 # remove all arguments with a default value from the list
313 defaults_count = len(spec.defaults or []) # spec.defaults can be None
314 # a[:-0] ~ a[:0] ~ [] -> replace defaults_count == 0 by None to get
316 args = spec.args[:(-defaults_count or None)]
318 # params with defaults were removed, leftover allowed are:
319 # * self (technically should be first-parameter-of-instance-method but whatever)
320 # * any parameter mapping to a converter
322 (arg == 'self' or arg in rule._converters)
325 def enumerate_pages(self, cr, uid, ids, query_string=None, context=None):
326 """ Available pages in the website/CMS. This is mostly used for links
327 generation and can be overridden by modules setting up new HTML
328 controllers for dynamic pages (e.g. blog).
330 By default, returns template views marked as pages.
332 :param str query_string: a (user-provided) string, fetches pages
334 :returns: a list of mappings with two keys: ``name`` is the displayable
335 name of the resource (page), ``url`` is the absolute URL
337 :rtype: list({name: str, url: str})
339 router = request.httprequest.app.get_db_router(request.db)
340 # Force enumeration to be performed as public user
341 uid = request.website.user_id.id
343 for rule in router.iter_rules():
344 if not self.rule_is_enumerable(rule):
347 converters = rule._converters
348 filtered = bool(converters)
350 # allow single converter as decided by fp, checked by
352 [(name, converter)] = converters.items()
353 converter_values = converter.generate(
354 request.cr, uid, query=query_string, context=context)
355 generated = ({k: v} for k, v in itertools.izip(
356 itertools.repeat(name), converter_values))
358 # force single iteration for literal urls
361 for values in generated:
362 domain_part, url = rule.build(values, append_unknown=False)
363 page = {'name': url, 'url': url}
367 if not filtered and query_string and not self.page_matches(cr, uid, page, query_string, context=context):
371 def search_pages(self, cr, uid, ids, needle=None, limit=None, context=None):
372 return list(itertools.islice(
373 self.enumerate_pages(cr, uid, ids, query_string=needle, context=context),
376 def page_matches(self, cr, uid, page, needle, context=None):
377 """ Checks that a "page" matches a user-provide search string.
379 The default implementation attempts to perform a non-contiguous
380 substring match of the page's name.
382 :param page: {'name': str, 'url': str}
386 haystack = page['name'].lower()
388 needle = iter(needle.lower())
392 for char in haystack:
393 if char != n: continue
395 n = next(needle, end)
396 # found all characters of needle in haystack in order
402 def kanban(self, cr, uid, ids, model, domain, column, template, step=None, scope=None, orderby=None, context=None):
403 step = step and int(step) or 10
404 scope = scope and int(scope) or 5
405 orderby = orderby or "name"
407 get_args = dict(request.httprequest.args or {})
408 model_obj = self.pool[model]
409 relation = model_obj._columns.get(column)._obj
410 relation_obj = self.pool[relation]
412 get_args.setdefault('kanban', "")
413 kanban = get_args.pop('kanban')
414 kanban_url = "?%s&kanban=" % werkzeug.url_encode(get_args)
417 for col in kanban.split(","):
420 pages[int(col[0])] = int(col[1])
423 for group in model_obj.read_group(cr, uid, domain, ["id", column], groupby=column):
427 relation_id = group[column][0]
428 obj['column_id'] = relation_obj.browse(cr, uid, relation_id)
430 obj['kanban_url'] = kanban_url
431 for k, v in pages.items():
433 obj['kanban_url'] += "%s-%s" % (k, v)
436 number = model_obj.search(cr, uid, group['__domain'], count=True)
437 obj['page_count'] = int(math.ceil(float(number) / step))
438 obj['page'] = pages.get(relation_id) or 1
439 if obj['page'] > obj['page_count']:
440 obj['page'] = obj['page_count']
441 offset = (obj['page']-1) * step
442 obj['page_start'] = max(obj['page'] - int(math.floor((scope-1)/2)), 1)
443 obj['page_end'] = min(obj['page_start'] + (scope-1), obj['page_count'])
446 obj['domain'] = group['__domain']
449 obj['orderby'] = orderby
452 object_ids = model_obj.search(cr, uid, group['__domain'], limit=step, offset=offset, order=orderby)
453 obj['object_ids'] = model_obj.browse(cr, uid, object_ids)
460 'template': template,
462 return request.website._render("website.kanban_contain", values)
464 def kanban_col(self, cr, uid, ids, model, domain, page, template, step, orderby, context=None):
466 model_obj = self.pool[model]
467 domain = safe_eval(domain)
469 offset = (int(page)-1) * step
470 object_ids = model_obj.search(cr, uid, domain, limit=step, offset=offset, order=orderby)
471 object_ids = model_obj.browse(cr, uid, object_ids)
472 for object_id in object_ids:
473 html += request.website._render(template, {'object_id': object_id})
476 class website_menu(osv.osv):
477 _name = "website.menu"
478 _description = "Website Menu"
480 'name': fields.char('Menu', size=64, required=True, translate=True),
481 'url': fields.char('Url', translate=True),
482 'new_window': fields.boolean('New Window'),
483 'sequence': fields.integer('Sequence'),
484 # TODO: support multiwebsite once done for ir.ui.views
485 'website_id': fields.many2one('website', 'Website'),
486 'parent_id': fields.many2one('website.menu', 'Parent Menu', select=True, ondelete="cascade"),
487 'child_id': fields.one2many('website.menu', 'parent_id', string='Child Menus'),
488 'parent_left': fields.integer('Parent Left', select=True),
489 'parent_right': fields.integer('Parent Right', select=True),
492 def __defaults_sequence(self, cr, uid, context):
493 menu = self.search_read(cr, uid, [(1,"=",1)], ["sequence"], limit=1, order="sequence DESC", context=context)
494 return menu and menu[0]["sequence"] or 0
498 'sequence': __defaults_sequence,
502 _parent_order = 'sequence'
505 # would be better to take a menu_id as argument
506 def get_tree(self, cr, uid, website_id, context=None):
512 new_window=node.new_window,
513 sequence=node.sequence,
514 parent_id=node.parent_id.id,
517 for child in node.child_id:
518 menu_node['children'].append(make_tree(child))
520 menu = self.pool.get('website').browse(cr, uid, website_id, context=context).menu_id
521 return make_tree(menu)
523 def save(self, cr, uid, website_id, data, context=None):
524 def replace_id(old_id, new_id):
525 for menu in data['data']:
526 if menu['id'] == old_id:
528 if menu['parent_id'] == old_id:
529 menu['parent_id'] = new_id
530 to_delete = data['to_delete']
532 self.unlink(cr, uid, to_delete, context=context)
533 for menu in data['data']:
535 if isinstance(mid, str):
536 new_id = self.create(cr, uid, {'name': menu['name']}, context=context)
537 replace_id(mid, new_id)
538 for menu in data['data']:
539 self.write(cr, uid, [menu['id']], menu, context=context)
542 class ir_attachment(osv.osv):
543 _inherit = "ir.attachment"
544 def _website_url_get(self, cr, uid, ids, name, arg, context=None):
546 for attach in self.browse(cr, uid, ids, context=context):
548 result[attach.id] = attach.url
550 result[attach.id] = urlplus('/website/image', {
551 'model': 'ir.attachment',
556 def _datas_checksum(self, cr, uid, ids, name, arg, context=None):
558 (attach['id'], self._compute_checksum(attach))
559 for attach in self.read(
560 cr, uid, ids, ['res_model', 'res_id', 'type', 'datas'],
564 def _compute_checksum(self, attachment_dict):
565 if attachment_dict.get('res_model') == 'ir.ui.view'\
566 and not attachment_dict.get('res_id') and not attachment_dict.get('url')\
567 and attachment_dict.get('type', 'binary') == 'binary'\
568 and attachment_dict.get('datas'):
569 return hashlib.new('sha1', attachment_dict['datas']).hexdigest()
572 def _datas_big(self, cr, uid, ids, name, arg, context=None):
573 result = dict.fromkeys(ids, False)
574 if context and context.get('bin_size'):
577 for record in self.browse(cr, uid, ids, context=context):
578 if not record.datas: continue
580 result[record.id] = openerp.tools.image_resize_image_big(record.datas)
581 except IOError: # apparently the error PIL.Image.open raises
587 'datas_checksum': fields.function(_datas_checksum, size=40,
588 string="Datas checksum", type='char', store=True, select=True),
589 'website_url': fields.function(_website_url_get, string="Attachment URL", type='char'),
590 'datas_big': fields.function (_datas_big, type='binary', store=True,
591 string="Resized file content"),
592 'mimetype': fields.char('Mime Type', readonly=True),
595 def _add_mimetype_if_needed(self, values):
596 if values.get('datas_fname'):
597 values['mimetype'] = mimetypes.guess_type(values.get('datas_fname'))[0] or 'application/octet-stream'
599 def create(self, cr, uid, values, context=None):
600 chk = self._compute_checksum(values)
602 match = self.search(cr, uid, [('datas_checksum', '=', chk)], context=context)
605 self._add_mimetype_if_needed(values)
606 return super(ir_attachment, self).create(
607 cr, uid, values, context=context)
609 def write(self, cr, uid, ids, values, context=None):
610 self._add_mimetype_if_needed(values)
611 return super(ir_attachment, self).write(cr, uid, ids, values, context=context)
613 def try_remove(self, cr, uid, ids, context=None):
614 """ Removes a web-based image attachment if it is used by no view
617 Returns a dict mapping attachments which would not be removed (if any)
618 mapped to the views preventing their removal
620 Views = self.pool['ir.ui.view']
621 attachments_to_remove = []
622 # views blocking removal of the attachment
623 removal_blocked_by = {}
625 for attachment in self.browse(cr, uid, ids, context=context):
626 # in-document URLs are html-escaped, a straight search will not
628 url = werkzeug.utils.escape(attachment.website_url)
629 ids = Views.search(cr, uid, ["|", ('arch', 'like', '"%s"' % url), ('arch', 'like', "'%s'" % url)], context=context)
632 removal_blocked_by[attachment.id] = Views.read(
633 cr, uid, ids, ['name'], context=context)
635 attachments_to_remove.append(attachment.id)
636 if attachments_to_remove:
637 self.unlink(cr, uid, attachments_to_remove, context=context)
638 return removal_blocked_by
640 class res_partner(osv.osv):
641 _inherit = "res.partner"
643 def google_map_img(self, cr, uid, ids, zoom=8, width=298, height=298, context=None):
644 partner = self.browse(cr, uid, ids[0], context=context)
646 '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 ''),
647 'size': "%sx%s" % (height, width),
651 return urlplus('http://maps.googleapis.com/maps/api/staticmap' , params)
653 def google_map_link(self, cr, uid, ids, zoom=8, context=None):
654 partner = self.browse(cr, uid, ids[0], context=context)
656 '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 ''),
659 return urlplus('https://maps.google.com/maps' , params)
661 class res_company(osv.osv):
662 _inherit = "res.company"
663 def google_map_img(self, cr, uid, ids, zoom=8, width=298, height=298, context=None):
664 partner = self.browse(cr, openerp.SUPERUSER_ID, ids[0], context=context).partner_id
665 return partner and partner.google_map_img(zoom, width, height, context=context) or None
666 def google_map_link(self, cr, uid, ids, zoom=8, context=None):
667 partner = self.browse(cr, openerp.SUPERUSER_ID, ids[0], context=context).partner_id
668 return partner and partner.google_map_link(zoom, context=context) or None
670 class base_language_install(osv.osv_memory):
671 _inherit = "base.language.install"
673 'website_ids': fields.many2many('website', string='Websites to translate'),
676 def default_get(self, cr, uid, fields, context=None):
679 defaults = super(base_language_install, self).default_get(cr, uid, fields, context)
680 website_id = context.get('params', {}).get('website_id')
682 if 'website_ids' not in defaults:
683 defaults['website_ids'] = []
684 defaults['website_ids'].append(website_id)
687 def lang_install(self, cr, uid, ids, context=None):
690 action = super(base_language_install, self).lang_install(cr, uid, ids, context)
691 language_obj = self.browse(cr, uid, ids)[0]
692 website_ids = [website.id for website in language_obj['website_ids']]
693 lang_id = self.pool['res.lang'].search(cr, uid, [('code', '=', language_obj['lang'])])
694 if website_ids and lang_id:
695 data = {'language_ids': [(4, lang_id[0])]}
696 self.pool['website'].write(cr, uid, website_ids, data)
697 params = context.get('params', {})
698 if 'url_return' in params:
700 'url': params['url_return'].replace('[lang]', language_obj['lang']),
701 'type': 'ir.actions.act_url',
706 class website_seo_metadata(osv.Model):
707 _name = 'website.seo.metadata'
708 _description = 'SEO metadata'
711 'website_meta_title': fields.char("Website meta title", translate=True),
712 'website_meta_description': fields.text("Website meta description", translate=True),
713 'website_meta_keywords': fields.char("Website meta keywords", translate=True),