[FIX] website page creation
[odoo/odoo.git] / addons / website / models / website.py
1 # -*- coding: utf-8 -*-
2 import fnmatch
3 import inspect
4 import itertools
5 import logging
6 import math
7 import re
8 import urllib
9 import urlparse
10
11 import simplejson
12 import werkzeug
13 import werkzeug.exceptions
14 import werkzeug.wrappers
15 # optional python-slugify import (https://github.com/un33k/python-slugify)
16 try:
17     import slugify as slugify_lib
18 except ImportError:
19     slugify_lib = None
20
21 import openerp
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, LazyResponse
25
26 logger = logging.getLogger(__name__)
27
28 def url_for(path_or_uri, lang=None, keep_query=None):
29     location = path_or_uri.strip()
30     url = urlparse.urlparse(location)
31     if request and not url.netloc and not url.scheme:
32         location = urlparse.urljoin(request.httprequest.path, location)
33         lang = lang or request.context.get('lang')
34         langs = [lg[0] for lg in request.website.get_languages()]
35         if location[0] == '/' and len(langs) > 1 and lang != request.website.default_lang_code:
36             ps = location.split('/')
37             if ps[1] in langs:
38                 ps[1] = lang
39             else:
40                 ps.insert(1, lang)
41             location = '/'.join(ps)
42         if keep_query:
43             url = urlparse.urlparse(location)
44             location = url.path
45             params = werkzeug.url_decode(url.query)
46             query_params = frozenset(werkzeug.url_decode(request.httprequest.query_string).keys())
47             for kq in keep_query:
48                 for param in fnmatch.filter(query_params, kq):
49                     params[param] = request.params[param]
50             params = werkzeug.urls.url_encode(params)
51             if params:
52                 location += '?%s' % params
53
54     return location
55
56 def slugify(s, max_length=None):
57     if slugify_lib:
58         return slugify_lib.slugify(s, max_length)
59     spaceless = re.sub(r'\s+', '-', s)
60     specialless = re.sub(r'[^-_A-Za-z0-9]', '', spaceless)
61     return specialless[:max_length]
62
63 def slug(value):
64     if isinstance(value, orm.browse_record):
65         # [(id, name)] = value.name_get()
66         id, name = value.id, value[value._rec_name]
67     else:
68         # assume name_search result tuple
69         id, name = value
70     return "%s-%d" % (slugify(name), id)
71
72 def urlplus(url, params):
73     if not params:
74         return url
75
76     # can't use urlencode because it encodes to (ascii, replace) in p2
77     return "%s?%s" % (url, '&'.join(
78         k + '=' + urllib.quote_plus(v.encode('utf-8') if isinstance(v, unicode) else str(v))
79         for k, v in params.iteritems()
80     ))
81
82 def quote_plus(value):
83     return urllib.quote_plus(value.encode('utf-8') if isinstance(value, unicode) else str(value))
84
85 class website(osv.osv):
86     def _get_menu_website(self, cr, uid, ids, context=None):
87         # IF a menu is changed, update all websites
88         return self.search(cr, uid, [], context=context)
89
90     def _get_menu(self, cr, uid, ids, name, arg, context=None):
91         root_domain = [('parent_id', '=', False)]
92         menus = self.pool.get('website.menu').search(cr, uid, root_domain, order='id', context=context)
93         menu = menus and menus[0] or False
94         return dict( map(lambda x: (x, menu), ids) )
95
96     def _get_public_user(self, cr, uid, ids, name='public_user', arg=(), context=None):
97         ref = self.get_public_user(cr, uid, context=context)
98         return dict( map(lambda x: (x, ref), ids) )
99
100     _name = "website" # Avoid website.website convention for conciseness (for new api). Got a special authorization from xmo and rco
101     _description = "Website"
102     _columns = {
103         'name': fields.char('Domain'),
104         'company_id': fields.many2one('res.company', string="Company"),
105         'language_ids': fields.many2many('res.lang', 'website_lang_rel', 'website_id', 'lang_id', 'Languages'),
106         'default_lang_id': fields.many2one('res.lang', string="Default language"),
107         'default_lang_code': fields.related('default_lang_id', 'code', type="char", string="Default language code", store=True),
108         'social_twitter': fields.char('Twitter Account'),
109         'social_facebook': fields.char('Facebook Account'),
110         'social_github': fields.char('GitHub Account'),
111         'social_linkedin': fields.char('LinkedIn Account'),
112         'social_youtube': fields.char('Youtube Account'),
113         'social_googleplus': fields.char('Google+ Account'),
114         'google_analytics_key': fields.char('Google Analytics Key'),
115         'public_user': fields.function(_get_public_user, relation='res.users', type='many2one', string='Public User'),
116         'menu_id': fields.function(_get_menu, relation='website.menu', type='many2one', string='Main Menu',
117             store= {
118                 'website.menu': (_get_menu_website, ['sequence','parent_id','website_id'], 10)
119             })
120     }
121
122     # cf. Wizard hack in website_views.xml
123     def noop(self, *args, **kwargs):
124         pass
125
126     def write(self, cr, uid, ids, vals, context=None):
127         self._get_languages.clear_cache(self)
128         return super(website, self).write(cr, uid, ids, vals, context)
129
130     def new_page(self, cr, uid, name, template='website.default_page', ispage=True, context=None):
131         context = context or {}
132         imd = self.pool.get('ir.model.data')
133         view = self.pool.get('ir.ui.view')
134         template_module, template_name = template.split('.')
135
136         # completely arbitrary max_length
137         page_name = slugify(name, max_length=50)
138         page_xmlid = "%s.%s" % (template_module, page_name)
139
140         try:
141             # existing page
142             imd.get_object_reference(cr, uid, template_module, page_name)
143         except ValueError:
144             # new page
145             _, template_id = imd.get_object_reference(cr, uid, template_module, template_name)
146             page_id = view.copy(cr, uid, template_id, context=context)
147             page = view.browse(cr, uid, page_id, context=context)
148             page.write({
149                 'arch': page.arch.replace(template, page_xmlid),
150                 'name': page_name,
151                 'page': ispage,
152             })
153             imd.create(cr, uid, {
154                 'name': page_name,
155                 'module': template_module,
156                 'model': 'ir.ui.view',
157                 'res_id': page_id,
158                 'noupdate': True
159             }, context=context)
160         return page_xmlid
161
162     def page_for_name(self, cr, uid, ids, name, module='website', context=None):
163         # whatever
164         return '%s.%s' % (module, slugify(name, max_length=50))
165
166     def page_exists(self, cr, uid, ids, name, module='website', context=None):
167         page = self.page_for_name(cr, uid, ids, name, module=module, context=context)
168
169         try:
170            self.pool["ir.model.data"].get_object_reference(cr, uid, module, name)
171         except:
172             return False
173
174     def get_public_user(self, cr, uid, context=None):
175         uid = openerp.SUPERUSER_ID
176         res = self.pool['ir.model.data'].get_object_reference(cr, uid, 'website', 'public_user')
177         return res and res[1] or False
178
179     @openerp.tools.ormcache(skiparg=3)
180     def _get_languages(self, cr, uid, id, context=None):
181         website = self.browse(cr, uid, id)
182         return [(lg.code, lg.name) for lg in website.language_ids]
183
184     def get_languages(self, cr, uid, ids, context=None):
185         return self._get_languages(cr, uid, ids[0])
186
187     def get_current_website(self, cr, uid, context=None):
188         # TODO: Select website, currently hard coded
189         return self.pool['website'].browse(cr, uid, 1, context=context)
190
191     def preprocess_request(self, cr, uid, ids, request, context=None):
192         # TODO FP: is_website_publisher and editable in context should be removed
193         # for performance reasons (1 query per image to load) but also to be cleaner
194         # I propose to replace this by a group 'base.group_website_publisher' on the
195         # view that requires it.
196         Access = request.registry['ir.model.access']
197         is_website_publisher = Access.check(cr, uid, 'ir.ui.view', 'write', False, context)
198
199         lang = request.context['lang']
200         is_master_lang = lang == request.website.default_lang_code
201
202         request.redirect = lambda url: werkzeug.utils.redirect(url_for(url))
203         request.context.update(
204             is_master_lang=is_master_lang,
205             editable=is_website_publisher,
206             translatable=not is_master_lang,
207         )
208
209     def get_template(self, cr, uid, ids, template, context=None):
210         if '.' not in template:
211             template = 'website.%s' % template
212         module, xmlid = template.split('.', 1)
213         model, view_id = request.registry["ir.model.data"].get_object_reference(cr, uid, module, xmlid)
214         return self.pool["ir.ui.view"].browse(cr, uid, view_id, context=context)
215
216     def _render(self, cr, uid, ids, template, values=None, context=None):
217         user = self.pool.get("res.users")
218         if not context:
219             context = {}
220
221         # Take a context
222         qweb_values = context.copy()
223         # add some values
224         if values:
225             qweb_values.update(values)
226         # fill some defaults
227         qweb_values.update(
228             request=request,
229             json=simplejson,
230             website=request.website,
231             url_for=url_for,
232             slug=slug,
233             res_company=request.website.company_id,
234             user_id=user.browse(cr, uid, uid),
235             quote_plus=quote_plus,
236         )
237         qweb_values.setdefault('editable', False)
238
239         # in edit mode ir.ui.view will tag nodes
240         context['inherit_branding'] = qweb_values['editable']
241
242         view = self.get_template(cr, uid, ids, template)
243
244         if 'main_object' not in qweb_values:
245             qweb_values['main_object'] = view
246         return view.render(qweb_values, engine='website.qweb', context=context)
247
248     def render(self, cr, uid, ids, template, values=None, status_code=None, context=None):
249         def callback(template, values, context):
250             return self._render(cr, uid, ids, template, values, context)
251         if values is None:
252             values = {}
253         return LazyResponse(callback, status_code=status_code, template=template, values=values, context=context)
254
255     def pager(self, cr, uid, ids, url, total, page=1, step=30, scope=5, url_args=None, context=None):
256         # Compute Pager
257         page_count = int(math.ceil(float(total) / step))
258
259         page = max(1, min(int(page), page_count))
260         scope -= 1
261
262         pmin = max(page - int(math.floor(scope/2)), 1)
263         pmax = min(pmin + scope, page_count)
264
265         if pmax - pmin < scope:
266             pmin = pmax - scope if pmax - scope > 0 else 1
267
268         def get_url(page):
269             _url = "%spage/%s/" % (url, page)
270             if url_args:
271                 _url = "%s?%s" % (_url, urllib.urlencode(url_args))
272             return _url
273
274         return {
275             "page_count": page_count,
276             "offset": (page - 1) * step,
277             "page": {
278                 'url': get_url(page),
279                 'num': page
280             },
281             "page_start": {
282                 'url': get_url(pmin),
283                 'num': pmin
284             },
285             "page_previous": {
286                 'url': get_url(max(pmin, page - 1)),
287                 'num': max(pmin, page - 1)
288             },
289             "page_next": {
290                 'url': get_url(min(pmax, page + 1)),
291                 'num': min(pmax, page + 1)
292             },
293             "page_end": {
294                 'url': get_url(pmax),
295                 'num': pmax
296             },
297             "pages": [
298                 {'url': get_url(page), 'num': page}
299                 for page in xrange(pmin, pmax+1)
300             ]
301         }
302
303     def rule_is_enumerable(self, rule):
304         """ Checks that it is possible to generate sensible GET queries for
305         a given rule (if the endpoint matches its own requirements)
306
307         :type rule: werkzeug.routing.Rule
308         :rtype: bool
309         """
310         endpoint = rule.endpoint
311         methods = rule.methods or ['GET']
312         converters = rule._converters.values()
313
314         return (
315             'GET' in methods
316             and endpoint.routing['type'] == 'http'
317             and endpoint.routing['auth'] in ('none', 'public')
318             and endpoint.routing.get('website', False)
319             # preclude combinatorial explosion by only allowing a single converter
320             and len(converters) <= 1
321             # ensure all converters on the rule are able to generate values for
322             # themselves
323             and all(hasattr(converter, 'generate') for converter in converters)
324         ) and self.endpoint_is_enumerable(rule)
325
326     def endpoint_is_enumerable(self, rule):
327         """ Verifies that it's possible to generate a valid url for the rule's
328         endpoint
329
330         :type rule: werkzeug.routing.Rule
331         :rtype: bool
332         """
333         spec = inspect.getargspec(rule.endpoint.method)
334
335         # if *args bail the fuck out, only dragons can live there
336         if spec.varargs:
337             return False
338
339         # remove all arguments with a default value from the list
340         defaults_count = len(spec.defaults or []) # spec.defaults can be None
341         # a[:-0] ~ a[:0] ~ [] -> replace defaults_count == 0 by None to get
342         # a[:None] ~ a
343         args = spec.args[:(-defaults_count or None)]
344
345         # params with defaults were removed, leftover allowed are:
346         # * self (technically should be first-parameter-of-instance-method but whatever)
347         # * any parameter mapping to a converter
348         return all(
349             (arg == 'self' or arg in rule._converters)
350             for arg in args)
351
352     def enumerate_pages(self, cr, uid, ids, query_string=None, context=None):
353         """ Available pages in the website/CMS. This is mostly used for links
354         generation and can be overridden by modules setting up new HTML
355         controllers for dynamic pages (e.g. blog).
356
357         By default, returns template views marked as pages.
358
359         :param str query_string: a (user-provided) string, fetches pages
360                                  matching the string
361         :returns: a list of mappings with two keys: ``name`` is the displayable
362                   name of the resource (page), ``url`` is the absolute URL
363                   of the same.
364         :rtype: list({name: str, url: str})
365         """
366         router = request.httprequest.app.get_db_router(request.db)
367         # Force enumeration to be performed as public user
368         uid = self.get_public_user(cr, uid, context=context)
369         for rule in router.iter_rules():
370             if not self.rule_is_enumerable(rule):
371                 continue
372
373             converters = rule._converters
374             filtered = bool(converters)
375             if converters:
376                 # allow single converter as decided by fp, checked by
377                 # rule_is_enumerable
378                 [(name, converter)] = converters.items()
379                 converter_values = converter.generate(
380                     request.cr, uid, query=query_string, context=context)
381                 generated = ({k: v} for k, v in itertools.izip(
382                     itertools.repeat(name), converter_values))
383             else:
384                 # force single iteration for literal urls
385                 generated = [{}]
386
387             for values in generated:
388                 domain_part, url = rule.build(values, append_unknown=False)
389                 page = {'name': url, 'url': url}
390
391                 if not filtered and query_string and not self.page_matches(cr, uid, page, query_string, context=context):
392                     continue
393                 yield page
394
395     def search_pages(self, cr, uid, ids, needle=None, limit=None, context=None):
396         return list(itertools.islice(
397             self.enumerate_pages(cr, uid, ids, query_string=needle, context=context),
398             limit))
399
400     def page_matches(self, cr, uid, page, needle, context=None):
401         """ Checks that a "page" matches a user-provide search string.
402
403         The default implementation attempts to perform a non-contiguous
404         substring match of the page's name.
405
406         :param page: {'name': str, 'url': str}
407         :param needle: str
408         :rtype: bool
409         """
410         haystack = page['name'].lower()
411
412         needle = iter(needle.lower())
413         n = next(needle)
414         end = object()
415
416         for char in haystack:
417             if char != n: continue
418
419             n = next(needle, end)
420             # found all characters of needle in haystack in order
421             if n is end:
422                 return True
423
424         return False
425
426     def kanban(self, cr, uid, ids, model, domain, column, template, step=None, scope=None, orderby=None, context=None):
427         step = step and int(step) or 10
428         scope = scope and int(scope) or 5
429         orderby = orderby or "name"
430
431         get_args = dict(request.httprequest.args or {})
432         model_obj = self.pool[model]
433         relation = model_obj._columns.get(column)._obj
434         relation_obj = self.pool[relation]
435
436         get_args.setdefault('kanban', "")
437         kanban = get_args.pop('kanban')
438         kanban_url = "?%s&kanban=" % urllib.urlencode(get_args)
439
440         pages = {}
441         for col in kanban.split(","):
442             if col:
443                 col = col.split("-")
444                 pages[int(col[0])] = int(col[1])
445
446         objects = []
447         for group in model_obj.read_group(cr, uid, domain, ["id", column], groupby=column):
448             obj = {}
449
450             # browse column
451             relation_id = group[column][0]
452             obj['column_id'] = relation_obj.browse(cr, uid, relation_id)
453
454             obj['kanban_url'] = kanban_url
455             for k, v in pages.items():
456                 if k != relation_id:
457                     obj['kanban_url'] += "%s-%s" % (k, v)
458
459             # pager
460             number = model_obj.search(cr, uid, group['__domain'], count=True)
461             obj['page_count'] = int(math.ceil(float(number) / step))
462             obj['page'] = pages.get(relation_id) or 1
463             if obj['page'] > obj['page_count']:
464                 obj['page'] = obj['page_count']
465             offset = (obj['page']-1) * step
466             obj['page_start'] = max(obj['page'] - int(math.floor((scope-1)/2)), 1)
467             obj['page_end'] = min(obj['page_start'] + (scope-1), obj['page_count'])
468
469             # view data
470             obj['domain'] = group['__domain']
471             obj['model'] = model
472             obj['step'] = step
473             obj['orderby'] = orderby
474
475             # browse objects
476             object_ids = model_obj.search(cr, uid, group['__domain'], limit=step, offset=offset, order=orderby)
477             obj['object_ids'] = model_obj.browse(cr, uid, object_ids)
478
479             objects.append(obj)
480
481         values = {
482             'objects': objects,
483             'range': range,
484             'template': template,
485         }
486         return request.website._render("website.kanban_contain", values)
487
488     def kanban_col(self, cr, uid, ids, model, domain, page, template, step, orderby, context=None):
489         html = ""
490         model_obj = self.pool[model]
491         domain = safe_eval(domain)
492         step = int(step)
493         offset = (int(page)-1) * step
494         object_ids = model_obj.search(cr, uid, domain, limit=step, offset=offset, order=orderby)
495         object_ids = model_obj.browse(cr, uid, object_ids)
496         for object_id in object_ids:
497             html += request.website._render(template, {'object_id': object_id})
498         return html
499
500 class website_menu(osv.osv):
501     _name = "website.menu"
502     _description = "Website Menu"
503     _columns = {
504         'name': fields.char('Menu', size=64, required=True, translate=True),
505         'url': fields.char('Url', required=True, translate=True),
506         'new_window': fields.boolean('New Window'),
507         'sequence': fields.integer('Sequence'),
508         # TODO: support multiwebsite once done for ir.ui.views
509         'website_id': fields.many2one('website', 'Website'),
510         'parent_id': fields.many2one('website.menu', 'Parent Menu', select=True, ondelete="cascade"),
511         'child_id': fields.one2many('website.menu', 'parent_id', string='Child Menus'),
512         'parent_left': fields.integer('Parent Left', select=True),
513         'parent_right': fields.integer('Parent Right', select=True),
514     }
515     _defaults = {
516         'url': '',
517         'sequence': 0,
518         'new_window': False,
519     }
520     _parent_store = True
521     _parent_order = 'sequence'
522     _order = "sequence"
523
524     # would be better to take a menu_id as argument
525     def get_tree(self, cr, uid, website_id, context=None):
526         def make_tree(node):
527             menu_node = dict(
528                 id=node.id,
529                 name=node.name,
530                 url=node.url,
531                 new_window=node.new_window,
532                 sequence=node.sequence,
533                 parent_id=node.parent_id.id,
534                 children=[],
535             )
536             for child in node.child_id:
537                 menu_node['children'].append(make_tree(child))
538             return menu_node
539         menu = self.pool.get('website').browse(cr, uid, website_id, context=context).menu_id
540         return make_tree(menu)
541
542     def save(self, cr, uid, website_id, data, context=None):
543         def replace_id(old_id, new_id):
544             for menu in data['data']:
545                 if menu['id'] == old_id:
546                     menu['id'] = new_id
547                 if menu['parent_id'] == old_id:
548                     menu['parent_id'] = new_id
549         to_delete = data['to_delete']
550         if to_delete:
551             self.unlink(cr, uid, to_delete, context=context)
552         for menu in data['data']:
553             mid = menu['id']
554             if isinstance(mid, str):
555                 new_id = self.create(cr, uid, {'name': menu['name']}, context=context)
556                 replace_id(mid, new_id)
557         for menu in data['data']:
558             self.write(cr, uid, [menu['id']], menu, context=context)
559         return True
560
561 class ir_attachment(osv.osv):
562     _inherit = "ir.attachment"
563     def _website_url_get(self, cr, uid, ids, name, arg, context=None):
564         result = {}
565         for attach in self.browse(cr, uid, ids, context=context):
566             if attach.type == 'url':
567                 result[attach.id] = attach.url
568             else:
569                 result[attach.id] = urlplus('/website/image', {
570                     'model': 'ir.attachment',
571                     'field': 'datas',
572                     'id': attach.id,
573                     'max_width': 1024,
574                     'max_height': 768,
575                 })
576         return result
577     _columns = {
578         'website_url': fields.function(_website_url_get, string="Attachment URL", type='char')
579     }
580
581 class res_partner(osv.osv):
582     _inherit = "res.partner"
583
584     def google_map_img(self, cr, uid, ids, zoom=8, width=298, height=298, context=None):
585         partner = self.browse(cr, uid, ids[0], context=context)
586         params = {
587             'center': '%s, %s %s, %s' % (partner.street, partner.city, partner.zip, partner.country_id and partner.country_id.name_get()[0][1] or ''),
588             'size': "%sx%s" % (height, width),
589             'zoom': zoom,
590             'sensor': 'false',
591         }
592         return urlplus('http://maps.googleapis.com/maps/api/staticmap' , params)
593
594     def google_map_link(self, cr, uid, ids, zoom=8, context=None):
595         partner = self.browse(cr, uid, ids[0], context=context)
596         params = {
597             'q': '%s, %s %s, %s' % (partner.street, partner.city, partner.zip, partner.country_id and partner.country_id.name_get()[0][1] or ''),
598         }
599         return urlplus('https://maps.google.be/maps' , params)
600
601 class res_company(osv.osv):
602     _inherit = "res.company"
603     def google_map_img(self, cr, uid, ids, zoom=8, width=298, height=298, context=None):
604         partner = self.browse(cr, openerp.SUPERUSER_ID, ids[0], context=context).partner_id
605         return partner and partner.google_map_img(zoom, width, height, context=context) or None
606     def google_map_link(self, cr, uid, ids, zoom=8, context=None):
607         partner = self.browse(cr, openerp.SUPERUSER_ID, ids[0], context=context).partner_id
608         return partner and partner.google_map_link(zoom, context=context) or None
609
610 class base_language_install(osv.osv_memory):
611     _inherit = "base.language.install"
612     _columns = {
613         'website_ids': fields.many2many('website', string='Websites to translate'),
614     }
615
616     def default_get(self, cr, uid, fields, context=None):
617         if context is None:
618             context = {}
619         defaults = super(base_language_install, self).default_get(cr, uid, fields, context)
620         website_id = context.get('params', {}).get('website_id')
621         if website_id:
622             if 'website_ids' not in defaults:
623                 defaults['website_ids'] = []
624             defaults['website_ids'].append(website_id)
625         return defaults
626
627     def lang_install(self, cr, uid, ids, context=None):
628         if context is None:
629             context = {}
630         action = super(base_language_install, self).lang_install(cr, uid, ids, context)
631         language_obj = self.browse(cr, uid, ids)[0]
632         website_ids = [website.id for website in language_obj['website_ids']]
633         lang_id = self.pool['res.lang'].search(cr, uid, [('code', '=', language_obj['lang'])])
634         if website_ids and lang_id:
635             data = {'language_ids': [(4, lang_id[0])]}
636             self.pool['website'].write(cr, uid, website_ids, data)
637         params = context.get('params', {})
638         if 'url_return' in params:
639             return {
640                 'url': params['url_return'].replace('[lang]', language_obj['lang']),
641                 'type': 'ir.actions.act_url',
642                 'target': 'self'
643             }
644         return action
645
646 class SeoMetadata(osv.Model):
647     _name = 'website.seo.metadata'
648     _description = 'SEO metadata'
649
650     _columns = {
651         'website_meta_title': fields.char("Website meta title", size=70, translate=True),
652         'website_meta_description': fields.text("Website meta description", size=160, translate=True),
653         'website_meta_keywords': fields.char("Website meta keywords", translate=True),
654     }
655
656 # vim:et: