[MERGE] Forward-port of latest 7.0 bugfixes, up to rev. 9929 revid:dle@openerp.com...
[odoo/odoo.git] / addons / share / wizard / share_wizard.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2010 Tiny SPRL (<http://tiny.be>).
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version.
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details.
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
19 #
20 ##############################################################################
21 import logging
22 import random
23 import time
24 from urllib import quote_plus
25 import uuid
26 from openerp import SUPERUSER_ID
27
28 import simplejson
29
30 from openerp import tools
31 from openerp.osv import fields, osv
32 from openerp.osv import expression
33 from openerp.tools.translate import _
34 from openerp.tools.safe_eval import safe_eval
35 import openerp
36 _logger = logging.getLogger(__name__)
37
38 FULL_ACCESS = ('perm_read', 'perm_write', 'perm_create', 'perm_unlink')
39 READ_WRITE_ACCESS = ('perm_read', 'perm_write')
40 READ_ONLY_ACCESS = ('perm_read',)
41 UID_ROOT = 1
42
43 # Pseudo-domain to represent an empty filter, constructed using
44 # osv.expression's DUMMY_LEAF
45 DOMAIN_ALL = [(1, '=', 1)]
46
47 # A good selection of easy to read password characters (e.g. no '0' vs 'O', etc.)
48 RANDOM_PASS_CHARACTERS = 'aaaabcdeeeefghjkmnpqrstuvwxyzAAAABCDEEEEFGHJKLMNPQRSTUVWXYZ23456789'
49 def generate_random_pass():
50     return ''.join(random.sample(RANDOM_PASS_CHARACTERS,10))
51
52 class share_wizard(osv.TransientModel):
53     _name = 'share.wizard'
54     _description = 'Share Wizard'
55
56     def _assert(self, condition, error_message, context=None):
57         """Raise a user error with the given message if condition is not met.
58            The error_message should have been translated with _().
59         """
60         if not condition:
61             raise osv.except_osv(_('Sharing access cannot be created.'), error_message)
62
63     def has_group(self, cr, uid, module, group_xml_id, context=None):
64         """Returns True if current user is a member of the group identified by the module, group_xml_id pair."""
65         # if the group was deleted or does not exist, we say NO (better safe than sorry)
66         try:
67             model, group_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, module, group_xml_id)
68         except ValueError:
69             return False
70         return group_id in self.pool.get('res.users').read(cr, uid, uid, ['groups_id'], context=context)['groups_id']
71
72     def has_share(self, cr, uid, unused_param, context=None):
73         return self.has_group(cr, uid, module='share', group_xml_id='group_share_user', context=context)
74
75     def _user_type_selection(self, cr, uid, context=None):
76         """Selection values may be easily overridden/extended via inheritance"""
77         return [('embedded', _('Direct link or embed code')), ('emails',_('Emails')), ]
78
79     """Override of create() to auto-compute the action name"""
80     def create(self, cr, uid, values, context=None):
81         if 'action_id' in values and not 'name' in values:
82             action = self.pool.get('ir.actions.actions').browse(cr, uid, values['action_id'], context=context)
83             values['name'] = action.name
84         return super(share_wizard,self).create(cr, uid, values, context=context)
85
86     def share_url_template(self, cr, uid, _ids, context=None):
87         # NOTE: take _ids in parameter to allow usage through browse_record objects
88         base_url = self.pool.get('ir.config_parameter').get_param(cr, uid, 'web.base.url', default='', context=context)
89         if base_url:
90             base_url += '/login?db=%(dbname)s&login=%(login)s&key=%(password)s'
91             extra = context and context.get('share_url_template_extra_arguments')
92             if extra:
93                 base_url += '&' + '&'.join('%s=%%(%s)s' % (x,x) for x in extra)
94             hash_ = context and context.get('share_url_template_hash_arguments')
95             if hash_:
96                 base_url += '#' + '&'.join('%s=%%(%s)s' % (x,x) for x in hash_)
97         return base_url
98
99     def _share_root_url(self, cr, uid, ids, _fieldname, _args, context=None):
100         result = dict.fromkeys(ids, '')
101         data = dict(dbname=cr.dbname, login='', password='')
102         for this in self.browse(cr, uid, ids, context=context):
103             result[this.id] = this.share_url_template() % data
104         return result
105
106     def _generate_embedded_code(self, wizard, options=None):
107         cr = wizard._cr
108         uid = wizard._uid
109         context = wizard._context
110         if options is None:
111             options = {}
112
113         js_options = {}
114         title = options['title'] if 'title' in options else wizard.embed_option_title
115         search = (options['search'] if 'search' in options else wizard.embed_option_search) if wizard.access_mode != 'readonly' else False
116
117         if not title:
118             js_options['display_title'] = False
119         if search:
120             js_options['search_view'] = True
121
122         js_options_str = (', ' + simplejson.dumps(js_options)) if js_options else ''
123
124         base_url = self.pool.get('ir.config_parameter').get_param(cr, uid, 'web.base.url', default=None, context=context)
125         user = wizard.result_line_ids[0]
126
127         return """
128 <script type="text/javascript" src="%(base_url)s/web/webclient/js"></script>
129 <script type="text/javascript">
130     new openerp.init(%(init)s).web.embed(%(server)s, %(dbname)s, %(login)s, %(password)s,%(action)d%(options)s);
131 </script> """ % {
132             'init': simplejson.dumps(openerp.conf.server_wide_modules),
133             'base_url': base_url or '',
134             'server': simplejson.dumps(base_url),
135             'dbname': simplejson.dumps(cr.dbname),
136             'login': simplejson.dumps(user.login),
137             'password': simplejson.dumps(user.password),
138             'action': user.user_id.action_id.id,
139             'options': js_options_str,
140         }
141
142     def _embed_code(self, cr, uid, ids, _fn, _args, context=None):
143         result = dict.fromkeys(ids, '')
144         for this in self.browse(cr, uid, ids, context=context):
145             result[this.id] = self._generate_embedded_code(this)
146         return result
147
148     def _embed_url(self, cr, uid, ids, _fn, _args, context=None):
149         if context is None:
150             context = {}
151         result = dict.fromkeys(ids, '')
152         for this in self.browse(cr, uid, ids, context=context):
153             if this.result_line_ids:
154                 ctx = dict(context, share_url_template_hash_arguments=['action'])
155                 user = this.result_line_ids[0]
156                 data = dict(dbname=cr.dbname, login=user.login, password=user.password, action=this.action_id.id)
157                 result[this.id] = this.share_url_template(context=ctx) % data
158         return result
159
160
161     _columns = {
162         'action_id': fields.many2one('ir.actions.act_window', 'Action to share', required=True,
163                 help="The action that opens the screen containing the data you wish to share."),
164         'view_type': fields.char('Current View Type', size=32, required=True),
165         'domain': fields.char('Domain', size=256, help="Optional domain for further data filtering"),
166         'user_type': fields.selection(lambda s, *a, **k: s._user_type_selection(*a, **k),'Sharing method', required=True,
167                      help="Select the type of user(s) you would like to share data with."),
168         'new_users': fields.text("Emails"),
169         'email_1': fields.char('New user email', size=64),
170         'email_2': fields.char('New user email', size=64),
171         'email_3': fields.char('New user email', size=64),
172         'invite': fields.boolean('Invite users to OpenSocial record'),
173         'access_mode': fields.selection([('readonly','Can view'),('readwrite','Can edit')],'Access Mode', required=True,
174                                         help="Access rights to be granted on the shared documents."),
175         'result_line_ids': fields.one2many('share.wizard.result.line', 'share_wizard_id', 'Summary', readonly=True),
176         'share_root_url': fields.function(_share_root_url, string='Share Access URL', type='char', size=512, readonly=True,
177                                 help='Main access page for users that are granted shared access'),
178         'name': fields.char('Share Title', size=64, required=True, help="Title for the share (displayed to users as menu and shortcut name)"),
179         'record_name': fields.char('Record name', size=128, help="Name of the shared record, if sharing a precise record"),
180         'message': fields.text("Personal Message", help="An optional personal message, to be included in the email notification."),
181         'embed_code': fields.function(_embed_code, type='text', string='Code',
182             help="Embed this code in your documents to provide a link to the "\
183                   "shared document."),
184         'embed_option_title': fields.boolean('Display title'),
185         'embed_option_search': fields.boolean('Display search view'),
186         'embed_url': fields.function(_embed_url, string='Share URL', type='char', size=512, readonly=True),
187     }
188     _defaults = {
189         'view_type': 'page',
190         'user_type' : 'embedded',
191         'invite': False,
192         'domain': lambda self, cr, uid, context, *a: context.get('domain', '[]'),
193         'action_id': lambda self, cr, uid, context, *a: context.get('action_id'),
194         'access_mode': 'readwrite',
195         'embed_option_title': True,
196         'embed_option_search': True,
197     }
198
199     def has_email(self, cr, uid, context=None):
200         return bool(self.pool.get('res.users').browse(cr, uid, uid, context=context).email)
201
202     def go_step_1(self, cr, uid, ids, context=None):
203         wizard_data = self.browse(cr,uid,ids,context)[0]
204         if wizard_data.user_type == 'emails' and not self.has_email(cr, uid, context=context):
205             raise osv.except_osv(_('No email address configured'),
206                                  _('You must configure your email address in the user preferences before using the Share button.'))
207         model, res_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'share', 'action_share_wizard_step1')
208         action = self.pool[model].read(cr, uid, res_id, context=context)
209         action['res_id'] = ids[0]
210         action.pop('context', '')
211         return action
212
213     def _create_share_group(self, cr, uid, wizard_data, context=None):
214         group_obj = self.pool.get('res.groups')
215         share_group_name = '%s: %s (%d-%s)' %('Shared', wizard_data.name, uid, time.time())
216         # create share group without putting admin in it
217         return group_obj.create(cr, UID_ROOT, {'name': share_group_name, 'share': True}, {'noadmin': True})
218
219     def _create_new_share_users(self, cr, uid, wizard_data, group_id, context=None):
220         """Create one new res.users record for each email address provided in
221            wizard_data.new_users, ignoring already existing users.
222            Populates wizard_data.result_line_ids with one new line for
223            each user (existing or not). New users will also have a value
224            for the password field, so they can receive it by email.
225            Returns the ids of the created users, and the ids of the
226            ignored, existing ones."""
227         if context is None:
228             context = {}
229         user_obj = self.pool.get('res.users')
230         current_user = user_obj.browse(cr, UID_ROOT, uid, context=context)
231         # modify context to disable shortcuts when creating share users
232         context['noshortcut'] = True
233         context['no_reset_password'] = True
234         created_ids = []
235         existing_ids = []
236         if wizard_data.user_type == 'emails':
237             # get new user list from email data
238             new_users = (wizard_data.new_users or '').split('\n')
239             new_users += [wizard_data.email_1 or '', wizard_data.email_2 or '', wizard_data.email_3 or '']
240             for new_user in new_users:
241                 # Ignore blank lines
242                 new_user = new_user.strip()
243                 if not new_user: continue
244                 # Ignore the user if it already exists.
245                 if not wizard_data.invite:
246                     existing = user_obj.search(cr, UID_ROOT, [('login', '=', new_user)])
247                 else:
248                     existing = user_obj.search(cr, UID_ROOT, [('email', '=', new_user)])
249                 existing_ids.extend(existing)
250                 if existing:
251                     new_line = { 'user_id': existing[0],
252                                  'newly_created': False}
253                     wizard_data.write({'result_line_ids': [(0,0,new_line)]})
254                     continue
255                 new_pass = generate_random_pass()
256                 user_id = user_obj.create(cr, UID_ROOT, {
257                         'login': new_user,
258                         'password': new_pass,
259                         'name': new_user,
260                         'email': new_user,
261                         'groups_id': [(6,0,[group_id])],
262                         'share': True,
263                         'company_id': current_user.company_id.id,
264                         'company_ids': [(6, 0, [current_user.company_id.id])],
265                 }, context)
266                 new_line = { 'user_id': user_id,
267                              'password': new_pass,
268                              'newly_created': True}
269                 wizard_data.write({'result_line_ids': [(0,0,new_line)]})
270                 created_ids.append(user_id)
271
272         elif wizard_data.user_type == 'embedded':
273             new_login = 'embedded-%s' % (uuid.uuid4().hex,)
274             new_pass = generate_random_pass()
275             user_id = user_obj.create(cr, UID_ROOT, {
276                 'login': new_login,
277                 'password': new_pass,
278                 'name': new_login,
279                 'groups_id': [(6,0,[group_id])],
280                 'share': True,
281                 'company_id': current_user.company_id.id,
282                 'company_ids': [(6, 0, [current_user.company_id.id])],
283             }, context)
284             new_line = { 'user_id': user_id,
285                          'password': new_pass,
286                          'newly_created': True}
287             wizard_data.write({'result_line_ids': [(0,0,new_line)]})
288             created_ids.append(user_id)
289
290         return created_ids, existing_ids
291
292     def _create_shortcut(self, cr, uid, values, context=None):
293         if context is None:
294             context = {}
295         new_context = context.copy()
296         for key in context:
297             if key.startswith('default_'):
298                 del new_context[key]
299
300         dataobj = self.pool.get('ir.model.data')
301         menu_id = dataobj._get_id(cr, uid, 'base', 'menu_administration_shortcut')
302         shortcut_menu_id  = int(dataobj.read(cr, uid, menu_id, ['res_id'], new_context)['res_id'])
303         action_id = self.pool.get('ir.actions.act_window').create(cr, UID_ROOT, values, new_context)
304         menu_data = {'name': values['name'],
305                      'sequence': 10,
306                      'action': 'ir.actions.act_window,'+str(action_id),
307                      'parent_id': shortcut_menu_id,
308                      'icon': 'STOCK_JUSTIFY_FILL'}
309         menu_obj = self.pool.get('ir.ui.menu')
310         menu_id =  menu_obj.create(cr, UID_ROOT, menu_data)
311         sc_data = {'name': values['name'], 'sequence': UID_ROOT,'res_id': menu_id }
312         self.pool.get('ir.ui.view_sc').create(cr, uid, sc_data, new_context)
313
314         # update menu cache
315         user_groups = set(self.pool.get('res.users').read(cr, UID_ROOT, uid, ['groups_id'])['groups_id'])
316         key = (cr.dbname, shortcut_menu_id, tuple(user_groups))
317         menu_obj._cache[key] = True
318         return action_id
319
320     def _cleanup_action_context(self, context_str, user_id):
321         """Returns a dict representing the context_str evaluated (safe_eval) as
322            a dict where items that are not useful for shared actions
323            have been removed. If the evaluation of context_str as a
324            dict fails, context_str is returned unaltered.
325
326            :param user_id: the integer uid to be passed as 'uid' in the
327                            evaluation context
328            """
329         result = False
330         if context_str:
331             try:
332                 context = safe_eval(context_str, tools.UnquoteEvalContext(), nocopy=True)
333                 result = dict(context)
334                 for key in context:
335                     # Remove all context keys that seem to toggle default
336                     # filters based on the current user, as it makes no sense
337                     # for shared users, who would not see any data by default.
338                     if key and key.startswith('search_default_') and 'user_id' in key:
339                         result.pop(key)
340             except Exception:
341                 # Note: must catch all exceptions, as UnquoteEvalContext may cause many
342                 #       different exceptions, as it shadows builtins.
343                 _logger.debug("Failed to cleanup action context as it does not parse server-side", exc_info=True)
344                 result = context_str
345         return result
346
347     def _shared_action_def(self, cr, uid, wizard_data, context=None):
348         copied_action = wizard_data.action_id
349
350         if wizard_data.access_mode == 'readonly':
351             view_mode = wizard_data.view_type
352             view_id = copied_action.view_id.id if copied_action.view_id.type == wizard_data.view_type else False
353         else:
354             view_mode = copied_action.view_mode
355             view_id = copied_action.view_id.id
356
357
358         action_def = {
359             'name': wizard_data.name,
360             'domain': copied_action.domain,
361             'context': self._cleanup_action_context(wizard_data.action_id.context, uid),
362             'res_model': copied_action.res_model,
363             'view_mode': view_mode,
364             'view_type': copied_action.view_type,
365             'search_view_id': copied_action.search_view_id.id if wizard_data.access_mode != 'readonly' else False,
366             'view_id': view_id,
367             'auto_search': True,
368         }
369         if copied_action.view_ids:
370             action_def['view_ids'] = [(0,0,{'sequence': x.sequence,
371                                             'view_mode': x.view_mode,
372                                             'view_id': x.view_id.id })
373                                       for x in copied_action.view_ids
374                                       if (wizard_data.access_mode != 'readonly' or x.view_mode == wizard_data.view_type)
375                                      ]
376         return action_def
377
378     def _setup_action_and_shortcut(self, cr, uid, wizard_data, user_ids, make_home, context=None):
379         """Create a shortcut to reach the shared data, as well as the corresponding action, for
380            each user in ``user_ids``, and assign it as their home action if ``make_home`` is True.
381            Meant to be overridden for special cases.
382         """
383         values = self._shared_action_def(cr, uid, wizard_data, context=None)
384         user_obj = self.pool.get('res.users')
385         for user_id in user_ids:
386             action_id = self._create_shortcut(cr, user_id, values)
387             if make_home:
388                 # We do this only for new share users, as existing ones already have their initial home
389                 # action. Resetting to the default menu does not work well as the menu is rather empty
390                 # and does not contain the shortcuts in most cases.
391                 user_obj.write(cr, UID_ROOT, [user_id], {'action_id': action_id})
392
393     def _get_recursive_relations(self, cr, uid, model, ttypes, relation_fields=None, suffix=None, context=None):
394         """Returns list of tuples representing recursive relationships of type ``ttypes`` starting from
395            model with ID ``model_id``.
396
397            :param model: browsable model to start loading relationships from
398            :param ttypes: list of relationship types to follow (e.g: ['one2many','many2many'])
399            :param relation_fields: list of previously followed relationship tuples - to avoid duplicates
400                                    during recursion
401            :param suffix: optional suffix to append to the field path to reach the main object
402         """
403         if relation_fields is None:
404             relation_fields = []
405         local_rel_fields = []
406         models = [x[1].model for x in relation_fields]
407         model_obj = self.pool.get('ir.model')
408         model_osv = self.pool[model.model]
409         for colinfo in model_osv._all_columns.itervalues():
410             coldef = colinfo.column
411             coltype = coldef._type
412             relation_field = None
413             if coltype in ttypes and colinfo.column._obj not in models:
414                 relation_model_id = model_obj.search(cr, UID_ROOT, [('model','=',coldef._obj)])[0]
415                 relation_model_browse = model_obj.browse(cr, UID_ROOT, relation_model_id, context=context)
416                 relation_osv = self.pool[coldef._obj]
417                 #skip virtual one2many fields (related, ...) as there is no reverse relationship
418                 if coltype == 'one2many' and hasattr(coldef, '_fields_id'):
419                     # don't record reverse path if it's not a real m2o (that happens, but rarely)
420                     dest_model_ci = relation_osv._all_columns
421                     reverse_rel = coldef._fields_id
422                     if reverse_rel in dest_model_ci and dest_model_ci[reverse_rel].column._type == 'many2one':
423                         relation_field = ('%s.%s'%(reverse_rel, suffix)) if suffix else reverse_rel
424                 local_rel_fields.append((relation_field, relation_model_browse))
425                 for parent in relation_osv._inherits:
426                     if parent not in models:
427                         parent_model = self.pool[parent]
428                         parent_colinfos = parent_model._all_columns
429                         parent_model_browse = model_obj.browse(cr, UID_ROOT,
430                                                                model_obj.search(cr, UID_ROOT, [('model','=',parent)]))[0]
431                         if relation_field and coldef._fields_id in parent_colinfos:
432                             # inverse relationship is available in the parent
433                             local_rel_fields.append((relation_field, parent_model_browse))
434                         else:
435                             # TODO: can we setup a proper rule to restrict inherited models
436                             # in case the parent does not contain the reverse m2o?
437                             local_rel_fields.append((None, parent_model_browse))
438                 if relation_model_id != model.id and coltype in ['one2many', 'many2many']:
439                     local_rel_fields += self._get_recursive_relations(cr, uid, relation_model_browse,
440                         [coltype], relation_fields + local_rel_fields, suffix=relation_field, context=context)
441         return local_rel_fields
442
443     def _get_relationship_classes(self, cr, uid, model, context=None):
444         """Computes the *relationship classes* reachable from the given
445            model. The 4 relationship classes are:
446            - [obj0]: the given model itself (and its parents via _inherits, if any)
447            - [obj1]: obj0 and all other models recursively accessible from
448                      obj0 via one2many relationships
449            - [obj2]: obj0 and all other models recursively accessible from
450                      obj0 via one2many and many2many relationships
451            - [obj3]: all models recursively accessible from obj1 via many2one
452                      relationships
453
454            Each class is returned as a list of pairs [(field,model_browse)], where
455            ``model`` is the browse_record of a reachable ir.model, and ``field`` is
456            the dot-notation reverse relationship path coming from that model to obj0,
457            or None if there is no reverse path.
458            
459            :return: ([obj0], [obj1], [obj2], [obj3])
460            """
461         # obj0 class and its parents
462         obj0 = [(None, model)]
463         model_obj = self.pool[model.model]
464         ir_model_obj = self.pool.get('ir.model')
465         for parent in model_obj._inherits:
466             parent_model_browse = ir_model_obj.browse(cr, UID_ROOT,
467                     ir_model_obj.search(cr, UID_ROOT, [('model','=',parent)]))[0]
468             obj0 += [(None, parent_model_browse)]
469
470         obj1 = self._get_recursive_relations(cr, uid, model, ['one2many'], relation_fields=obj0, context=context)
471         obj2 = self._get_recursive_relations(cr, uid, model, ['one2many', 'many2many'], relation_fields=obj0, context=context)
472         obj3 = self._get_recursive_relations(cr, uid, model, ['many2one'], relation_fields=obj0, context=context)
473         for dummy, model in obj1:
474             obj3 += self._get_recursive_relations(cr, uid, model, ['many2one'], relation_fields=obj0, context=context)
475         return obj0, obj1, obj2, obj3
476
477     def _get_access_map_for_groups_and_models(self, cr, uid, group_ids, model_ids, context=None):
478         model_access_obj = self.pool.get('ir.model.access')
479         user_right_ids = model_access_obj.search(cr, uid,
480             [('group_id', 'in', group_ids), ('model_id', 'in', model_ids)],
481             context=context)
482         user_access_matrix = {}
483         if user_right_ids:
484             for access_right in model_access_obj.browse(cr, uid, user_right_ids, context=context):
485                 access_line = user_access_matrix.setdefault(access_right.model_id.model, set())
486                 for perm in FULL_ACCESS:
487                     if getattr(access_right, perm, 0):
488                         access_line.add(perm)
489         return user_access_matrix
490
491     def _add_access_rights_for_share_group(self, cr, uid, group_id, mode, fields_relations, context=None):
492         """Adds access rights to group_id on object models referenced in ``fields_relations``,
493            intersecting with access rights of current user to avoid granting too much rights
494         """
495         model_access_obj = self.pool.get('ir.model.access')
496         user_obj = self.pool.get('res.users')
497         target_model_ids = [x[1].id for x in fields_relations]
498         perms_to_add = (mode == 'readonly') and READ_ONLY_ACCESS or READ_WRITE_ACCESS
499         current_user = user_obj.browse(cr, uid, uid, context=context)
500
501         current_user_access_map = self._get_access_map_for_groups_and_models(cr, uid,
502             [x.id for x in current_user.groups_id], target_model_ids, context=context)
503         group_access_map = self._get_access_map_for_groups_and_models(cr, uid,
504             [group_id], target_model_ids, context=context)
505         _logger.debug("Current user access matrix: %r", current_user_access_map)
506         _logger.debug("New group current access matrix: %r", group_access_map)
507
508         # Create required rights if allowed by current user rights and not
509         # already granted
510         for dummy, model in fields_relations:
511             # mail.message is transversal: it should not received directly the access rights
512             if model.model in ['mail.message']: continue
513             values = {
514                 'name': _('Copied access for sharing'),
515                 'group_id': group_id,
516                 'model_id': model.id,
517             }
518             current_user_access_line = current_user_access_map.get(model.model,set())
519             existing_group_access_line = group_access_map.get(model.model,set())
520             need_creation = False
521             for perm in perms_to_add:
522                 if perm in current_user_access_line \
523                    and perm not in existing_group_access_line:
524                     values.update({perm:True})
525                     group_access_map.setdefault(model.model, set()).add(perm)
526                     need_creation = True
527             if need_creation:
528                 model_access_obj.create(cr, UID_ROOT, values)
529                 _logger.debug("Creating access right for model %s with values: %r", model.model, values)
530
531     def _link_or_copy_current_user_rules(self, cr, current_user, group_id, fields_relations, context=None):
532         rule_obj = self.pool.get('ir.rule')
533         rules_done = set()
534         for group in current_user.groups_id:
535             for dummy, model in fields_relations:
536                 for rule in group.rule_groups:
537                     if rule.id in rules_done:
538                         continue
539                     rules_done.add(rule.id)
540                     if rule.model_id.id == model.id:
541                         if 'user.' in rule.domain_force:
542                             # Above pattern means there is likely a condition
543                             # specific to current user, so we must copy the rule using
544                             # the evaluated version of the domain.
545                             # And it's better to copy one time too much than too few
546                             rule_obj.copy(cr, UID_ROOT, rule.id, default={
547                                 'name': '%s %s' %(rule.name, _('(Copy for sharing)')),
548                                 'groups': [(6,0,[group_id])],
549                                 'domain_force': rule.domain, # evaluated version!
550                             })
551                             _logger.debug("Copying rule %s (%s) on model %s with domain: %s", rule.name, rule.id, model.model, rule.domain_force)
552                         else:
553                             # otherwise we can simply link the rule to keep it dynamic
554                             rule_obj.write(cr, SUPERUSER_ID, [rule.id], {
555                                     'groups': [(4,group_id)]
556                                 })
557                             _logger.debug("Linking rule %s (%s) on model %s with domain: %s", rule.name, rule.id, model.model, rule.domain_force)
558
559     def _check_personal_rule_or_duplicate(self, cr, group_id, rule, context=None):
560         """Verifies that the given rule only belongs to the given group_id, otherwise
561            duplicate it for the current group, and unlink the previous one.
562            The duplicated rule has the original domain copied verbatim, without
563            any evaluation.
564            Returns the final rule to use (browse_record), either the original one if it
565            only belongs to this group, or the copy."""
566         if len(rule.groups) == 1:
567             return rule
568         # duplicate it first:
569         rule_obj = self.pool.get('ir.rule')
570         new_id = rule_obj.copy(cr, UID_ROOT, rule.id,
571                                default={
572                                        'name': '%s %s' %(rule.name, _('(Duplicated for modified sharing permissions)')),
573                                        'groups': [(6,0,[group_id])],
574                                        'domain_force': rule.domain_force, # non evaluated!
575                                })
576         _logger.debug("Duplicating rule %s (%s) (domain: %s) for modified access ", rule.name, rule.id, rule.domain_force)
577         # then disconnect from group_id:
578         rule.write({'groups':[(3,group_id)]}) # disconnects, does not delete!
579         return rule_obj.browse(cr, UID_ROOT, new_id, context=context)
580
581     def _create_or_combine_sharing_rule(self, cr, current_user, wizard_data, group_id, model_id, domain, restrict=False, rule_name=None, context=None):
582         """Add a new ir.rule entry for model_id and domain on the target group_id.
583            If ``restrict`` is True, instead of adding a rule, the domain is
584            combined with AND operator with all existing rules in the group, to implement
585            an additional restriction (as of 6.1, multiple rules in the same group are
586            OR'ed by default, so a restriction must alter all existing rules)
587
588            This is necessary because the personal rules of the user that is sharing
589            are first copied to the new share group. Afterwards the filters used for
590            sharing are applied as an additional layer of rules, which are likely to
591            apply to the same model. The default rule algorithm would OR them (as of 6.1),
592            which would result in a combined set of permission that could be larger
593            than those of the user that is sharing! Hence we must forcefully AND the
594            rules at this stage.
595            One possibly undesirable effect can appear when sharing with a
596            pre-existing group, in which case altering pre-existing rules would not
597            be desired. This is addressed in the portal module.
598            """
599         if rule_name is None:
600             rule_name = _('Sharing filter created by user %s (%s) for group %s') % \
601                             (current_user.name, current_user.login, group_id)
602         rule_obj = self.pool.get('ir.rule')
603         rule_ids = rule_obj.search(cr, UID_ROOT, [('groups', 'in', group_id), ('model_id', '=', model_id)])
604         if rule_ids:
605             for rule in rule_obj.browse(cr, UID_ROOT, rule_ids, context=context):
606                 if rule.domain_force == domain:
607                     # don't create it twice!
608                     if restrict:
609                         continue
610                     else:
611                         _logger.debug("Ignoring sharing rule on model %s with domain: %s the same rule exists already", model_id, domain)
612                         return
613                 if restrict:
614                     # restricting existing rules is done by adding the clause
615                     # with an AND, but we can't alter the rule if it belongs to
616                     # other groups, so we duplicate if needed
617                     rule = self._check_personal_rule_or_duplicate(cr, group_id, rule, context=context)
618                     eval_ctx = rule_obj._eval_context_for_combinations()
619                     org_domain = expression.normalize_domain(eval(rule.domain_force, eval_ctx))
620                     new_clause = expression.normalize_domain(eval(domain, eval_ctx))
621                     combined_domain = expression.AND([new_clause, org_domain])
622                     rule.write({'domain_force': combined_domain, 'name': rule.name + _('(Modified)')})
623                     _logger.debug("Combining sharing rule %s on model %s with domain: %s", rule.id, model_id, domain)
624         if not rule_ids or not restrict:
625             # Adding the new rule in the group is ok for normal cases, because rules
626             # in the same group and for the same model will be combined with OR
627             # (as of v6.1), so the desired effect is achieved.
628             rule_obj.create(cr, UID_ROOT, {
629                 'name': rule_name,
630                 'model_id': model_id,
631                 'domain_force': domain,
632                 'groups': [(4,group_id)]
633                 })
634             _logger.debug("Created sharing rule on model %s with domain: %s", model_id, domain)
635
636     def _create_indirect_sharing_rules(self, cr, current_user, wizard_data, group_id, fields_relations, context=None):
637         rule_name = _('Indirect sharing filter created by user %s (%s) for group %s') % \
638                             (current_user.name, current_user.login, group_id)
639         try:
640             domain = safe_eval(wizard_data.domain)
641             if domain:
642                 for rel_field, model in fields_relations:
643                     # mail.message is transversal: it should not received directly the access rights
644                     if model.model in ['mail.message']: continue
645                     related_domain = []
646                     if not rel_field: continue
647                     for element in domain:
648                         if expression.is_leaf(element):
649                             left, operator, right = element
650                             left = '%s.%s'%(rel_field, left)
651                             element = left, operator, right
652                         related_domain.append(element)
653                     self._create_or_combine_sharing_rule(cr, current_user, wizard_data,
654                          group_id, model_id=model.id, domain=str(related_domain),
655                          rule_name=rule_name, restrict=True, context=context)
656         except Exception:
657             _logger.exception('Failed to create share access')
658             raise osv.except_osv(_('Sharing access cannot be created.'),
659                                  _('Sorry, the current screen and filter you are trying to share are not supported at the moment.\nYou may want to try a simpler filter.'))
660
661     def _check_preconditions(self, cr, uid, wizard_data, context=None):
662         self._assert(wizard_data.action_id and wizard_data.access_mode,
663                      _('Action and Access Mode are required to create a shared access.'),
664                      context=context)
665         self._assert(self.has_share(cr, uid, wizard_data, context=context),
666                      _('You must be a member of the Share/User group to use the share wizard.'),
667                      context=context)
668         if wizard_data.user_type == 'emails':
669             self._assert((wizard_data.new_users or wizard_data.email_1 or wizard_data.email_2 or wizard_data.email_3),
670                      _('Please indicate the emails of the persons to share with, one per line.'),
671                      context=context)
672
673     def _create_share_users_group(self, cr, uid, wizard_data, context=None):
674         """Creates the appropriate share group and share users, and populates
675            result_line_ids of wizard_data with one line for each user.
676
677            :return: a tuple composed of the new group id (to which the shared access should be granted),
678                 the ids of the new share users that have been created and the ids of the existing share users
679         """
680         group_id = self._create_share_group(cr, uid, wizard_data, context=context)
681         # First create any missing user, based on the email addresses provided
682         new_ids, existing_ids = self._create_new_share_users(cr, uid, wizard_data, group_id, context=context)
683         # Finally, setup the new action and shortcut for the users.
684         if existing_ids:
685             # existing users still need to join the new group
686             self.pool.get('res.users').write(cr, UID_ROOT, existing_ids, {
687                                                 'groups_id': [(4,group_id)],
688                                              })
689             # existing user don't need their home action replaced, only a new shortcut
690             self._setup_action_and_shortcut(cr, uid, wizard_data, existing_ids, make_home=False, context=context)
691         if new_ids:
692             # new users need a new shortcut AND a home action
693             self._setup_action_and_shortcut(cr, uid, wizard_data, new_ids, make_home=True, context=context)
694         return group_id, new_ids, existing_ids
695
696     def go_step_2(self, cr, uid, ids, context=None):
697         wizard_data = self.browse(cr, uid, ids[0], context=context)
698         self._check_preconditions(cr, uid, wizard_data, context=context)
699
700         # Create shared group and users
701         group_id, new_ids, existing_ids = self._create_share_users_group(cr, uid, wizard_data, context=context)
702
703         current_user = self.pool.get('res.users').browse(cr, uid, uid, context=context)
704
705         model_obj = self.pool.get('ir.model')
706         model_id = model_obj.search(cr, uid, [('model','=', wizard_data.action_id.res_model)])[0]
707         model = model_obj.browse(cr, uid, model_id, context=context)
708         
709         # ACCESS RIGHTS
710         # We have several classes of objects that should receive different access rights:
711         # Let:
712         #   - [obj0] be the target model itself (and its parents via _inherits, if any)
713         #   - [obj1] be the target model and all other models recursively accessible from
714         #            obj0 via one2many relationships
715         #   - [obj2] be the target model and all other models recursively accessible from
716         #            obj0 via one2many and many2many relationships
717         #   - [obj3] be all models recursively accessible from obj1 via many2one relationships
718         #            (currently not used)
719         obj0, obj1, obj2, obj3 = self._get_relationship_classes(cr, uid, model, context=context)
720         mode = wizard_data.access_mode
721
722         # Add access to [obj0] and [obj1] according to chosen mode
723         self._add_access_rights_for_share_group(cr, uid, group_id, mode, obj0, context=context)
724         self._add_access_rights_for_share_group(cr, uid, group_id, mode, obj1, context=context)
725
726         # Add read-only access (always) to [obj2]
727         self._add_access_rights_for_share_group(cr, uid, group_id, 'readonly', obj2, context=context)
728
729         # IR.RULES
730         #   A. On [obj0], [obj1], [obj2]: add all rules from all groups of
731         #     the user that is sharing
732         #     Warning: rules must be copied instead of linked if they contain a reference
733         #     to uid or if the rule is shared with other groups (and it must be replaced correctly)
734         #   B. On [obj0]: 1 rule with domain of shared action
735         #   C. For each model in [obj1]: 1 rule in the form:
736         #           many2one_rel.domain_of_obj0
737         #        where many2one_rel is the many2one used in the definition of the
738         #        one2many, and domain_of_obj0 is the sharing domain
739         #        For example if [obj0] is project.project with a domain of
740         #                ['id', 'in', [1,2]]
741         #        then we will have project.task in [obj1] and we need to create this
742         #        ir.rule on project.task:
743         #                ['project_id.id', 'in', [1,2]]
744
745         # A.
746         all_relations = obj0 + obj1 + obj2
747         self._link_or_copy_current_user_rules(cr, current_user, group_id, all_relations, context=context)
748         # B.
749         main_domain = wizard_data.domain if wizard_data.domain != '[]' else str(DOMAIN_ALL)
750         self._create_or_combine_sharing_rule(cr, current_user, wizard_data,
751                      group_id, model_id=model.id, domain=main_domain,
752                      restrict=True, context=context)
753         # C.
754         self._create_indirect_sharing_rules(cr, current_user, wizard_data, group_id, obj1, context=context)
755
756         # refresh wizard_data
757         wizard_data = self.browse(cr, uid, ids[0], context=context)
758         
759         # EMAILS AND NOTIFICATIONS
760         #  A. Not invite: as before
761         #     -> send emails to destination users
762         #  B. Invite (OpenSocial)
763         #     -> subscribe all users (existing and new) to the record
764         #     -> send a notification with a summary to the current record
765         #     -> send a notification to all users; users allowing to receive
766         #        emails in preferences will receive it
767         #        new users by default receive all notifications by email
768         
769         # A.
770         if not wizard_data.invite:
771             self.send_emails(cr, uid, wizard_data, context=context)
772         # B.
773         else:
774             # Invite (OpenSocial): automatically subscribe users to the record
775             res_id = 0
776             for cond in safe_eval(main_domain):
777                 if cond[0] == 'id':
778                     res_id = cond[2]
779             # Record id not found: issue
780             if res_id <= 0:
781                 raise osv.except_osv(_('Record id not found'), _('The share engine has not been able to fetch a record_id for your invitation.'))
782             self.pool[model.model].message_subscribe(cr, uid, [res_id], new_ids + existing_ids, context=context)
783             # self.send_invite_email(cr, uid, wizard_data, context=context)
784             # self.send_invite_note(cr, uid, model.model, res_id, wizard_data, context=context)
785         
786         # CLOSE
787         #  A. Not invite: as before
788         #  B. Invite: skip summary screen, get back to the record
789         
790         # A.
791         if not wizard_data.invite:
792             dummy, step2_form_view_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'share', 'share_step2_form')
793             return {
794                 'name': _('Shared access created!'),
795                 'view_type': 'form',
796                 'view_mode': 'form',
797                 'res_model': 'share.wizard',
798                 'view_id': False,
799                 'res_id': ids[0],
800                 'views': [(step2_form_view_id, 'form'), (False, 'tree'), (False, 'calendar'), (False, 'graph')],
801                 'type': 'ir.actions.act_window',
802                 'target': 'new'
803             }
804         # B.
805         else:
806             return {
807                 'view_type': 'form',
808                 'view_mode': 'form',
809                 'res_model': model.model,
810                 'view_id': False,
811                 'res_id': res_id,
812                 'views': [(False, 'form'), (False, 'tree'), (False, 'calendar'), (False, 'graph')],
813                 'type': 'ir.actions.act_window',
814             }
815             
816
817     def send_invite_note(self, cr, uid, model_name, res_id, wizard_data, context=None):
818         subject = _('Invitation')
819         body = 'has been <b>shared</b> with'
820         tmp_idx = 0
821         for result_line in wizard_data.result_line_ids:
822             body += ' @%s' % (result_line.user_id.login)
823             if tmp_idx < len(wizard_data.result_line_ids)-2:
824                 body += ','
825             elif tmp_idx == len(wizard_data.result_line_ids)-2:
826                 body += ' and'
827         body += '.'
828         return self.pool[model_name].message_post(cr, uid, [res_id], body=body, context=context)
829     
830     def send_invite_email(self, cr, uid, wizard_data, context=None):
831         # TDE Note: not updated because will disappear
832         message_obj = self.pool.get('mail.message')
833         notification_obj = self.pool.get('mail.notification')
834         user = self.pool.get('res.users').browse(cr, UID_ROOT, uid)
835         if not user.email:
836             raise osv.except_osv(_('Email Required'), _('The current user must have an email address configured in User Preferences to be able to send outgoing emails.'))
837         
838         # TODO: also send an HTML version of this mail
839         for result_line in wizard_data.result_line_ids:
840             email_to = result_line.user_id.email
841             if not email_to:
842                 continue
843             subject = _('Invitation to collaborate about %s') % (wizard_data.record_name)
844             body = _("Hello,\n\n")
845             body += _("I have shared %s (%s) with you!\n\n") % (wizard_data.record_name, wizard_data.name)
846             if wizard_data.message:
847                 body += "%s\n\n" % (wizard_data.message)
848             if result_line.newly_created:
849                 body += _("The documents are not attached, you can view them online directly on my OpenERP server at:\n    %s\n\n") % (result_line.share_url)
850                 body += _("These are your credentials to access this protected area:\n")
851                 body += "%s: %s" % (_("Username"), result_line.user_id.login) + "\n"
852                 body += "%s: %s" % (_("Password"), result_line.password) + "\n"
853                 body += "%s: %s" % (_("Database"), cr.dbname) + "\n"
854             body += _("The documents have been automatically added to your subscriptions.\n\n")
855             body += '%s\n\n' % ((user.signature or ''))
856             body += "--\n"
857             body += _("OpenERP is a powerful and user-friendly suite of Business Applications (CRM, Sales, HR, etc.)\n"
858                       "It is open source and can be found on http://www.openerp.com.")
859             msg_id = message_obj.schedule_with_attach(cr, uid, user.email, [email_to], subject, body, model='', context=context)
860             notification_obj.create(cr, uid, {'user_id': result_line.user_id.id, 'message_id': msg_id}, context=context)
861     
862     def send_emails(self, cr, uid, wizard_data, context=None):
863         _logger.info('Sending share notifications by email...')
864         mail_mail = self.pool.get('mail.mail')
865         user = self.pool.get('res.users').browse(cr, UID_ROOT, uid)
866         if not user.email:
867             raise osv.except_osv(_('Email Required'), _('The current user must have an email address configured in User Preferences to be able to send outgoing emails.'))
868         
869         # TODO: also send an HTML version of this mail
870         mail_ids = []
871         for result_line in wizard_data.result_line_ids:
872             email_to = result_line.user_id.email
873             if not email_to:
874                 continue
875             subject = wizard_data.name
876             body = _("Hello,\n\n")
877             body += _("I've shared %s with you!\n\n") % wizard_data.name
878             body += _("The documents are not attached, you can view them online directly on my OpenERP server at:\n    %s\n\n") % (result_line.share_url)
879             if wizard_data.message:
880                 body += '%s\n\n' % (wizard_data.message)
881             if result_line.newly_created:
882                 body += _("These are your credentials to access this protected area:\n")
883                 body += "%s: %s\n" % (_("Username"), result_line.user_id.login)
884                 body += "%s: %s\n" % (_("Password"), result_line.password)
885                 body += "%s: %s\n" % (_("Database"), cr.dbname)
886             else:
887                 body += _("The documents have been automatically added to your current OpenERP documents.\n")
888                 body += _("You may use your current login (%s) and password to view them.\n") % result_line.user_id.login
889             body += "\n\n%s\n\n" % ( (user.signature or '') )
890             body += "--\n"
891             body += _("OpenERP is a powerful and user-friendly suite of Business Applications (CRM, Sales, HR, etc.)\n"
892                       "It is open source and can be found on http://www.openerp.com.")
893             mail_ids.append(mail_mail.create(cr, uid, {
894                     'email_from': user.email,
895                     'email_to': email_to,
896                     'subject': subject,
897                     'body_html': '<pre>%s</pre>' % body}, context=context))
898         # force direct delivery, as users expect instant notification
899         mail_mail.send(cr, uid, mail_ids, context=context)
900         _logger.info('%d share notification(s) sent.', len(mail_ids))
901
902     def onchange_embed_options(self, cr, uid, ids, opt_title, opt_search, context=None):
903         wizard = self.browse(cr, uid, ids[0], context)
904         options = dict(title=opt_title, search=opt_search)
905         return {'value': {'embed_code': self._generate_embedded_code(wizard, options)}}
906
907
908 class share_result_line(osv.osv_memory):
909     _name = 'share.wizard.result.line'
910     _rec_name = 'user_id'
911
912
913     def _share_url(self, cr, uid, ids, _fieldname, _args, context=None):
914         result = dict.fromkeys(ids, '')
915         for this in self.browse(cr, uid, ids, context=context):
916             data = dict(dbname=cr.dbname, login=this.login, password=this.password)
917             if this.share_wizard_id and this.share_wizard_id.action_id:
918                 data['action_id'] = this.share_wizard_id.action_id.id
919             ctx = dict(context, share_url_template_hash_arguments=['action_id'])
920             result[this.id] = this.share_wizard_id.share_url_template(context=ctx) % data
921         return result
922
923     _columns = {
924         'user_id': fields.many2one('res.users', required=True, readonly=True),
925         'login': fields.related('user_id', 'login', string='Login', type='char', size=64, required=True, readonly=True),
926         'password': fields.char('Password', size=64, readonly=True),
927         'share_url': fields.function(_share_url, string='Share URL', type='char', size=512),
928         'share_wizard_id': fields.many2one('share.wizard', 'Share Wizard', required=True, ondelete='cascade'),
929         'newly_created': fields.boolean('Newly created', readonly=True),
930     }
931     _defaults = {
932         'newly_created': True,
933     }
934
935 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: