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