[FIX] share wizard: delete wizard lines in cascade
[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.get(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         created_ids = []
234         existing_ids = []
235         if wizard_data.user_type == 'emails':
236             # get new user list from email data
237             new_users = (wizard_data.new_users or '').split('\n')
238             new_users += [wizard_data.email_1 or '', wizard_data.email_2 or '', wizard_data.email_3 or '']
239             for new_user in new_users:
240                 # Ignore blank lines
241                 new_user = new_user.strip()
242                 if not new_user: continue
243                 # Ignore the user if it already exists.
244                 if not wizard_data.invite:
245                     existing = user_obj.search(cr, UID_ROOT, [('login', '=', new_user)])
246                 else:
247                     existing = user_obj.search(cr, UID_ROOT, [('email', '=', new_user)])
248                 existing_ids.extend(existing)
249                 if existing:
250                     new_line = { 'user_id': existing[0],
251                                  'newly_created': False}
252                     wizard_data.write({'result_line_ids': [(0,0,new_line)]})
253                     continue
254                 new_pass = generate_random_pass()
255                 user_id = user_obj.create(cr, UID_ROOT, {
256                         'login': new_user,
257                         'password': new_pass,
258                         'name': new_user,
259                         'email': new_user,
260                         'groups_id': [(6,0,[group_id])],
261                         'share': True,
262                         'company_id': current_user.company_id.id,
263                         'company_ids': [(6, 0, [current_user.company_id.id])],
264                 }, context)
265                 new_line = { 'user_id': user_id,
266                              'password': new_pass,
267                              'newly_created': True}
268                 wizard_data.write({'result_line_ids': [(0,0,new_line)]})
269                 created_ids.append(user_id)
270
271         elif wizard_data.user_type == 'embedded':
272             new_login = 'embedded-%s' % (uuid.uuid4().hex,)
273             new_pass = generate_random_pass()
274             user_id = user_obj.create(cr, UID_ROOT, {
275                 'login': new_login,
276                 'password': new_pass,
277                 'name': new_login,
278                 'groups_id': [(6,0,[group_id])],
279                 'share': True,
280                 'company_id': current_user.company_id.id,
281                 'company_ids': [(6, 0, [current_user.company_id.id])],
282             }, context)
283             new_line = { 'user_id': user_id,
284                          'password': new_pass,
285                          'newly_created': True}
286             wizard_data.write({'result_line_ids': [(0,0,new_line)]})
287             created_ids.append(user_id)
288
289         return created_ids, existing_ids
290
291     def _create_shortcut(self, cr, uid, values, context=None):
292         if context is None:
293             context = {}
294         new_context = context.copy()
295         for key in context:
296             if key.startswith('default_'):
297                 del new_context[key]
298
299         dataobj = self.pool.get('ir.model.data')
300         menu_id = dataobj._get_id(cr, uid, 'base', 'menu_administration_shortcut')
301         shortcut_menu_id  = int(dataobj.read(cr, uid, menu_id, ['res_id'], new_context)['res_id'])
302         action_id = self.pool.get('ir.actions.act_window').create(cr, UID_ROOT, values, new_context)
303         menu_data = {'name': values['name'],
304                      'sequence': 10,
305                      'action': 'ir.actions.act_window,'+str(action_id),
306                      'parent_id': shortcut_menu_id,
307                      'icon': 'STOCK_JUSTIFY_FILL'}
308         menu_obj = self.pool.get('ir.ui.menu')
309         menu_id =  menu_obj.create(cr, UID_ROOT, menu_data)
310         sc_data = {'name': values['name'], 'sequence': UID_ROOT,'res_id': menu_id }
311         self.pool.get('ir.ui.view_sc').create(cr, uid, sc_data, new_context)
312
313         # update menu cache
314         user_groups = set(self.pool.get('res.users').read(cr, UID_ROOT, uid, ['groups_id'])['groups_id'])
315         key = (cr.dbname, shortcut_menu_id, tuple(user_groups))
316         menu_obj._cache[key] = True
317         return action_id
318
319     def _cleanup_action_context(self, context_str, user_id):
320         """Returns a dict representing the context_str evaluated (safe_eval) as
321            a dict where items that are not useful for shared actions
322            have been removed. If the evaluation of context_str as a
323            dict fails, context_str is returned unaltered.
324
325            :param user_id: the integer uid to be passed as 'uid' in the
326                            evaluation context
327            """
328         result = False
329         if context_str:
330             try:
331                 context = safe_eval(context_str, tools.UnquoteEvalContext(), nocopy=True)
332                 result = dict(context)
333                 for key in context:
334                     # Remove all context keys that seem to toggle default
335                     # filters based on the current user, as it makes no sense
336                     # for shared users, who would not see any data by default.
337                     if key and key.startswith('search_default_') and 'user_id' in key:
338                         result.pop(key)
339             except Exception:
340                 # Note: must catch all exceptions, as UnquoteEvalContext may cause many
341                 #       different exceptions, as it shadows builtins.
342                 _logger.debug("Failed to cleanup action context as it does not parse server-side", exc_info=True)
343                 result = context_str
344         return result
345
346     def _shared_action_def(self, cr, uid, wizard_data, context=None):
347         copied_action = wizard_data.action_id
348
349         if wizard_data.access_mode == 'readonly':
350             view_mode = wizard_data.view_type
351             view_id = copied_action.view_id.id if copied_action.view_id.type == wizard_data.view_type else False
352         else:
353             view_mode = copied_action.view_mode
354             view_id = copied_action.view_id.id
355
356
357         action_def = {
358             'name': wizard_data.name,
359             'domain': copied_action.domain,
360             'context': self._cleanup_action_context(wizard_data.action_id.context, uid),
361             'res_model': copied_action.res_model,
362             'view_mode': view_mode,
363             'view_type': copied_action.view_type,
364             'search_view_id': copied_action.search_view_id.id if wizard_data.access_mode != 'readonly' else False,
365             'view_id': view_id,
366             'auto_search': True,
367         }
368         if copied_action.view_ids:
369             action_def['view_ids'] = [(0,0,{'sequence': x.sequence,
370                                             'view_mode': x.view_mode,
371                                             'view_id': x.view_id.id })
372                                       for x in copied_action.view_ids
373                                       if (wizard_data.access_mode != 'readonly' or x.view_mode == wizard_data.view_type)
374                                      ]
375         return action_def
376
377     def _setup_action_and_shortcut(self, cr, uid, wizard_data, user_ids, make_home, context=None):
378         """Create a shortcut to reach the shared data, as well as the corresponding action, for
379            each user in ``user_ids``, and assign it as their home action if ``make_home`` is True.
380            Meant to be overridden for special cases.
381         """
382         values = self._shared_action_def(cr, uid, wizard_data, context=None)
383         user_obj = self.pool.get('res.users')
384         for user_id in user_ids:
385             action_id = self._create_shortcut(cr, user_id, values)
386             if make_home:
387                 # We do this only for new share users, as existing ones already have their initial home
388                 # action. Resetting to the default menu does not work well as the menu is rather empty
389                 # and does not contain the shortcuts in most cases.
390                 user_obj.write(cr, UID_ROOT, [user_id], {'action_id': action_id})
391
392     def _get_recursive_relations(self, cr, uid, model, ttypes, relation_fields=None, suffix=None, context=None):
393         """Returns list of tuples representing recursive relationships of type ``ttypes`` starting from
394            model with ID ``model_id``.
395
396            :param model: browsable model to start loading relationships from
397            :param ttypes: list of relationship types to follow (e.g: ['one2many','many2many'])
398            :param relation_fields: list of previously followed relationship tuples - to avoid duplicates
399                                    during recursion
400            :param suffix: optional suffix to append to the field path to reach the main object
401         """
402         if relation_fields is None:
403             relation_fields = []
404         local_rel_fields = []
405         models = [x[1].model for x in relation_fields]
406         model_obj = self.pool.get('ir.model')
407         model_osv = self.pool.get(model.model)
408         for colinfo in model_osv._all_columns.itervalues():
409             coldef = colinfo.column
410             coltype = coldef._type
411             relation_field = None
412             if coltype in ttypes and colinfo.column._obj not in models:
413                 relation_model_id = model_obj.search(cr, UID_ROOT, [('model','=',coldef._obj)])[0]
414                 relation_model_browse = model_obj.browse(cr, UID_ROOT, relation_model_id, context=context)
415                 relation_osv = self.pool.get(coldef._obj)
416                 #skip virtual one2many fields (related, ...) as there is no reverse relationship
417                 if coltype == 'one2many' and hasattr(coldef, '_fields_id'):
418                     # don't record reverse path if it's not a real m2o (that happens, but rarely)
419                     dest_model_ci = relation_osv._all_columns
420                     reverse_rel = coldef._fields_id
421                     if reverse_rel in dest_model_ci and dest_model_ci[reverse_rel].column._type == 'many2one':
422                         relation_field = ('%s.%s'%(reverse_rel, suffix)) if suffix else reverse_rel
423                 local_rel_fields.append((relation_field, relation_model_browse))
424                 for parent in relation_osv._inherits:
425                     if parent not in models:
426                         parent_model = self.pool.get(parent)
427                         parent_colinfos = parent_model._all_columns
428                         parent_model_browse = model_obj.browse(cr, UID_ROOT,
429                                                                model_obj.search(cr, UID_ROOT, [('model','=',parent)]))[0]
430                         if relation_field and coldef._fields_id in parent_colinfos:
431                             # inverse relationship is available in the parent
432                             local_rel_fields.append((relation_field, parent_model_browse))
433                         else:
434                             # TODO: can we setup a proper rule to restrict inherited models
435                             # in case the parent does not contain the reverse m2o?
436                             local_rel_fields.append((None, parent_model_browse))
437                 if relation_model_id != model.id and coltype in ['one2many', 'many2many']:
438                     local_rel_fields += self._get_recursive_relations(cr, uid, relation_model_browse,
439                         [coltype], relation_fields + local_rel_fields, suffix=relation_field, context=context)
440         return local_rel_fields
441
442     def _get_relationship_classes(self, cr, uid, model, context=None):
443         """Computes the *relationship classes* reachable from the given
444            model. The 4 relationship classes are:
445            - [obj0]: the given model itself (and its parents via _inherits, if any)
446            - [obj1]: obj0 and all other models recursively accessible from
447                      obj0 via one2many relationships
448            - [obj2]: obj0 and all other models recursively accessible from
449                      obj0 via one2many and many2many relationships
450            - [obj3]: all models recursively accessible from obj1 via many2one
451                      relationships
452
453            Each class is returned as a list of pairs [(field,model_browse)], where
454            ``model`` is the browse_record of a reachable ir.model, and ``field`` is
455            the dot-notation reverse relationship path coming from that model to obj0,
456            or None if there is no reverse path.
457            
458            :return: ([obj0], [obj1], [obj2], [obj3])
459            """
460         # obj0 class and its parents
461         obj0 = [(None, model)]
462         model_obj = self.pool.get(model.model)
463         ir_model_obj = self.pool.get('ir.model')
464         for parent in model_obj._inherits:
465             parent_model_browse = ir_model_obj.browse(cr, UID_ROOT,
466                     ir_model_obj.search(cr, UID_ROOT, [('model','=',parent)]))[0]
467             obj0 += [(None, parent_model_browse)]
468
469         obj1 = self._get_recursive_relations(cr, uid, model, ['one2many'], relation_fields=obj0, context=context)
470         obj2 = self._get_recursive_relations(cr, uid, model, ['one2many', 'many2many'], relation_fields=obj0, context=context)
471         obj3 = self._get_recursive_relations(cr, uid, model, ['many2one'], relation_fields=obj0, context=context)
472         for dummy, model in obj1:
473             obj3 += self._get_recursive_relations(cr, uid, model, ['many2one'], relation_fields=obj0, context=context)
474         return obj0, obj1, obj2, obj3
475
476     def _get_access_map_for_groups_and_models(self, cr, uid, group_ids, model_ids, context=None):
477         model_access_obj = self.pool.get('ir.model.access')
478         user_right_ids = model_access_obj.search(cr, uid,
479             [('group_id', 'in', group_ids), ('model_id', 'in', model_ids)],
480             context=context)
481         user_access_matrix = {}
482         if user_right_ids:
483             for access_right in model_access_obj.browse(cr, uid, user_right_ids, context=context):
484                 access_line = user_access_matrix.setdefault(access_right.model_id.model, set())
485                 for perm in FULL_ACCESS:
486                     if getattr(access_right, perm, 0):
487                         access_line.add(perm)
488         return user_access_matrix
489
490     def _add_access_rights_for_share_group(self, cr, uid, group_id, mode, fields_relations, context=None):
491         """Adds access rights to group_id on object models referenced in ``fields_relations``,
492            intersecting with access rights of current user to avoid granting too much rights
493         """
494         model_access_obj = self.pool.get('ir.model.access')
495         user_obj = self.pool.get('res.users')
496         target_model_ids = [x[1].id for x in fields_relations]
497         perms_to_add = (mode == 'readonly') and READ_ONLY_ACCESS or READ_WRITE_ACCESS
498         current_user = user_obj.browse(cr, uid, uid, context=context)
499
500         current_user_access_map = self._get_access_map_for_groups_and_models(cr, uid,
501             [x.id for x in current_user.groups_id], target_model_ids, context=context)
502         group_access_map = self._get_access_map_for_groups_and_models(cr, uid,
503             [group_id], target_model_ids, context=context)
504         _logger.debug("Current user access matrix: %r", current_user_access_map)
505         _logger.debug("New group current access matrix: %r", group_access_map)
506
507         # Create required rights if allowed by current user rights and not
508         # already granted
509         for dummy, model in fields_relations:
510             # mail.message is transversal: it should not received directly the access rights
511             if model.model in ['mail.message']: continue
512             values = {
513                 'name': _('Copied access for sharing'),
514                 'group_id': group_id,
515                 'model_id': model.id,
516             }
517             current_user_access_line = current_user_access_map.get(model.model,set())
518             existing_group_access_line = group_access_map.get(model.model,set())
519             need_creation = False
520             for perm in perms_to_add:
521                 if perm in current_user_access_line \
522                    and perm not in existing_group_access_line:
523                     values.update({perm:True})
524                     group_access_map.setdefault(model.model, set()).add(perm)
525                     need_creation = True
526             if need_creation:
527                 model_access_obj.create(cr, UID_ROOT, values)
528                 _logger.debug("Creating access right for model %s with values: %r", model.model, values)
529
530     def _link_or_copy_current_user_rules(self, cr, current_user, group_id, fields_relations, context=None):
531         rule_obj = self.pool.get('ir.rule')
532         rules_done = set()
533         for group in current_user.groups_id:
534             for dummy, model in fields_relations:
535                 for rule in group.rule_groups:
536                     if rule.id in rules_done:
537                         continue
538                     rules_done.add(rule.id)
539                     if rule.model_id.id == model.id:
540                         if 'user.' in rule.domain_force:
541                             # Above pattern means there is likely a condition
542                             # specific to current user, so we must copy the rule using
543                             # the evaluated version of the domain.
544                             # And it's better to copy one time too much than too few
545                             rule_obj.copy(cr, UID_ROOT, rule.id, default={
546                                 'name': '%s %s' %(rule.name, _('(Copy for sharing)')),
547                                 'groups': [(6,0,[group_id])],
548                                 'domain_force': rule.domain, # evaluated version!
549                             })
550                             _logger.debug("Copying rule %s (%s) on model %s with domain: %s", rule.name, rule.id, model.model, rule.domain_force)
551                         else:
552                             # otherwise we can simply link the rule to keep it dynamic
553                             rule_obj.write(cr, SUPERUSER_ID, [rule.id], {
554                                     'groups': [(4,group_id)]
555                                 })
556                             _logger.debug("Linking rule %s (%s) on model %s with domain: %s", rule.name, rule.id, model.model, rule.domain_force)
557
558     def _check_personal_rule_or_duplicate(self, cr, group_id, rule, context=None):
559         """Verifies that the given rule only belongs to the given group_id, otherwise
560            duplicate it for the current group, and unlink the previous one.
561            The duplicated rule has the original domain copied verbatim, without
562            any evaluation.
563            Returns the final rule to use (browse_record), either the original one if it
564            only belongs to this group, or the copy."""
565         if len(rule.groups) == 1:
566             return rule
567         # duplicate it first:
568         rule_obj = self.pool.get('ir.rule')
569         new_id = rule_obj.copy(cr, UID_ROOT, rule.id,
570                                default={
571                                        'name': '%s %s' %(rule.name, _('(Duplicated for modified sharing permissions)')),
572                                        'groups': [(6,0,[group_id])],
573                                        'domain_force': rule.domain_force, # non evaluated!
574                                })
575         _logger.debug("Duplicating rule %s (%s) (domain: %s) for modified access ", rule.name, rule.id, rule.domain_force)
576         # then disconnect from group_id:
577         rule.write({'groups':[(3,group_id)]}) # disconnects, does not delete!
578         return rule_obj.browse(cr, UID_ROOT, new_id, context=context)
579
580     def _create_or_combine_sharing_rule(self, cr, current_user, wizard_data, group_id, model_id, domain, restrict=False, rule_name=None, context=None):
581         """Add a new ir.rule entry for model_id and domain on the target group_id.
582            If ``restrict`` is True, instead of adding a rule, the domain is
583            combined with AND operator with all existing rules in the group, to implement
584            an additional restriction (as of 6.1, multiple rules in the same group are
585            OR'ed by default, so a restriction must alter all existing rules)
586
587            This is necessary because the personal rules of the user that is sharing
588            are first copied to the new share group. Afterwards the filters used for
589            sharing are applied as an additional layer of rules, which are likely to
590            apply to the same model. The default rule algorithm would OR them (as of 6.1),
591            which would result in a combined set of permission that could be larger
592            than those of the user that is sharing! Hence we must forcefully AND the
593            rules at this stage.
594            One possibly undesirable effect can appear when sharing with a
595            pre-existing group, in which case altering pre-existing rules would not
596            be desired. This is addressed in the portal module.
597            """
598         if rule_name is None:
599             rule_name = _('Sharing filter created by user %s (%s) for group %s') % \
600                             (current_user.name, current_user.login, group_id)
601         rule_obj = self.pool.get('ir.rule')
602         rule_ids = rule_obj.search(cr, UID_ROOT, [('groups', 'in', group_id), ('model_id', '=', model_id)])
603         if rule_ids:
604             for rule in rule_obj.browse(cr, UID_ROOT, rule_ids, context=context):
605                 if rule.domain_force == domain:
606                     # don't create it twice!
607                     if restrict:
608                         continue
609                     else:
610                         _logger.debug("Ignoring sharing rule on model %s with domain: %s the same rule exists already", model_id, domain)
611                         return
612                 if restrict:
613                     # restricting existing rules is done by adding the clause
614                     # with an AND, but we can't alter the rule if it belongs to
615                     # other groups, so we duplicate if needed
616                     rule = self._check_personal_rule_or_duplicate(cr, group_id, rule, context=context)
617                     eval_ctx = rule_obj._eval_context_for_combinations()
618                     org_domain = expression.normalize_domain(eval(rule.domain_force, eval_ctx))
619                     new_clause = expression.normalize_domain(eval(domain, eval_ctx))
620                     combined_domain = expression.AND([new_clause, org_domain])
621                     rule.write({'domain_force': combined_domain, 'name': rule.name + _('(Modified)')})
622                     _logger.debug("Combining sharing rule %s on model %s with domain: %s", rule.id, model_id, domain)
623         if not rule_ids or not restrict:
624             # Adding the new rule in the group is ok for normal cases, because rules
625             # in the same group and for the same model will be combined with OR
626             # (as of v6.1), so the desired effect is achieved.
627             rule_obj.create(cr, UID_ROOT, {
628                 'name': rule_name,
629                 'model_id': model_id,
630                 'domain_force': domain,
631                 'groups': [(4,group_id)]
632                 })
633             _logger.debug("Created sharing rule on model %s with domain: %s", model_id, domain)
634
635     def _create_indirect_sharing_rules(self, cr, current_user, wizard_data, group_id, fields_relations, context=None):
636         rule_name = _('Indirect sharing filter created by user %s (%s) for group %s') % \
637                             (current_user.name, current_user.login, group_id)
638         try:
639             domain = safe_eval(wizard_data.domain)
640             if domain:
641                 for rel_field, model in fields_relations:
642                     # mail.message is transversal: it should not received directly the access rights
643                     if model.model in ['mail.message']: continue
644                     related_domain = []
645                     if not rel_field: continue
646                     for element in domain:
647                         if expression.is_leaf(element):
648                             left, operator, right = element
649                             left = '%s.%s'%(rel_field, left)
650                             element = left, operator, right
651                         related_domain.append(element)
652                     self._create_or_combine_sharing_rule(cr, current_user, wizard_data,
653                          group_id, model_id=model.id, domain=str(related_domain),
654                          rule_name=rule_name, restrict=True, context=context)
655         except Exception:
656             _logger.exception('Failed to create share access')
657             raise osv.except_osv(_('Sharing access cannot be created.'),
658                                  _('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.'))
659
660     def _check_preconditions(self, cr, uid, wizard_data, context=None):
661         self._assert(wizard_data.action_id and wizard_data.access_mode,
662                      _('Action and Access Mode are required to create a shared access.'),
663                      context=context)
664         self._assert(self.has_share(cr, uid, wizard_data, context=context),
665                      _('You must be a member of the Share/User group to use the share wizard.'),
666                      context=context)
667         if wizard_data.user_type == 'emails':
668             self._assert((wizard_data.new_users or wizard_data.email_1 or wizard_data.email_2 or wizard_data.email_3),
669                      _('Please indicate the emails of the persons to share with, one per line.'),
670                      context=context)
671
672     def _create_share_users_group(self, cr, uid, wizard_data, context=None):
673         """Creates the appropriate share group and share users, and populates
674            result_line_ids of wizard_data with one line for each user.
675
676            :return: a tuple composed of the new group id (to which the shared access should be granted),
677                 the ids of the new share users that have been created and the ids of the existing share users
678         """
679         group_id = self._create_share_group(cr, uid, wizard_data, context=context)
680         # First create any missing user, based on the email addresses provided
681         new_ids, existing_ids = self._create_new_share_users(cr, uid, wizard_data, group_id, context=context)
682         # Finally, setup the new action and shortcut for the users.
683         if existing_ids:
684             # existing users still need to join the new group
685             self.pool.get('res.users').write(cr, UID_ROOT, existing_ids, {
686                                                 'groups_id': [(4,group_id)],
687                                              })
688             # existing user don't need their home action replaced, only a new shortcut
689             self._setup_action_and_shortcut(cr, uid, wizard_data, existing_ids, make_home=False, context=context)
690         if new_ids:
691             # new users need a new shortcut AND a home action
692             self._setup_action_and_shortcut(cr, uid, wizard_data, new_ids, make_home=True, context=context)
693         return group_id, new_ids, existing_ids
694
695     def go_step_2(self, cr, uid, ids, context=None):
696         wizard_data = self.browse(cr, uid, ids[0], context=context)
697         self._check_preconditions(cr, uid, wizard_data, context=context)
698
699         # Create shared group and users
700         group_id, new_ids, existing_ids = self._create_share_users_group(cr, uid, wizard_data, context=context)
701
702         current_user = self.pool.get('res.users').browse(cr, uid, uid, context=context)
703
704         model_obj = self.pool.get('ir.model')
705         model_id = model_obj.search(cr, uid, [('model','=', wizard_data.action_id.res_model)])[0]
706         model = model_obj.browse(cr, uid, model_id, context=context)
707         
708         # ACCESS RIGHTS
709         # We have several classes of objects that should receive different access rights:
710         # Let:
711         #   - [obj0] be the target model itself (and its parents via _inherits, if any)
712         #   - [obj1] be the target model and all other models recursively accessible from
713         #            obj0 via one2many relationships
714         #   - [obj2] be the target model and all other models recursively accessible from
715         #            obj0 via one2many and many2many relationships
716         #   - [obj3] be all models recursively accessible from obj1 via many2one relationships
717         #            (currently not used)
718         obj0, obj1, obj2, obj3 = self._get_relationship_classes(cr, uid, model, context=context)
719         mode = wizard_data.access_mode
720
721         # Add access to [obj0] and [obj1] according to chosen mode
722         self._add_access_rights_for_share_group(cr, uid, group_id, mode, obj0, context=context)
723         self._add_access_rights_for_share_group(cr, uid, group_id, mode, obj1, context=context)
724
725         # Add read-only access (always) to [obj2]
726         self._add_access_rights_for_share_group(cr, uid, group_id, 'readonly', obj2, context=context)
727
728         # IR.RULES
729         #   A. On [obj0], [obj1], [obj2]: add all rules from all groups of
730         #     the user that is sharing
731         #     Warning: rules must be copied instead of linked if they contain a reference
732         #     to uid or if the rule is shared with other groups (and it must be replaced correctly)
733         #   B. On [obj0]: 1 rule with domain of shared action
734         #   C. For each model in [obj1]: 1 rule in the form:
735         #           many2one_rel.domain_of_obj0
736         #        where many2one_rel is the many2one used in the definition of the
737         #        one2many, and domain_of_obj0 is the sharing domain
738         #        For example if [obj0] is project.project with a domain of
739         #                ['id', 'in', [1,2]]
740         #        then we will have project.task in [obj1] and we need to create this
741         #        ir.rule on project.task:
742         #                ['project_id.id', 'in', [1,2]]
743
744         # A.
745         all_relations = obj0 + obj1 + obj2
746         self._link_or_copy_current_user_rules(cr, current_user, group_id, all_relations, context=context)
747         # B.
748         main_domain = wizard_data.domain if wizard_data.domain != '[]' else str(DOMAIN_ALL)
749         self._create_or_combine_sharing_rule(cr, current_user, wizard_data,
750                      group_id, model_id=model.id, domain=main_domain,
751                      restrict=True, context=context)
752         # C.
753         self._create_indirect_sharing_rules(cr, current_user, wizard_data, group_id, obj1, context=context)
754
755         # refresh wizard_data
756         wizard_data = self.browse(cr, uid, ids[0], context=context)
757         
758         # EMAILS AND NOTIFICATIONS
759         #  A. Not invite: as before
760         #     -> send emails to destination users
761         #  B. Invite (OpenSocial)
762         #     -> subscribe all users (existing and new) to the record
763         #     -> send a notification with a summary to the current record
764         #     -> send a notification to all users; users allowing to receive
765         #        emails in preferences will receive it
766         #        new users by default receive all notifications by email
767         
768         # A.
769         if not wizard_data.invite:
770             self.send_emails(cr, uid, wizard_data, context=context)
771         # B.
772         else:
773             # Invite (OpenSocial): automatically subscribe users to the record
774             res_id = 0
775             for cond in safe_eval(main_domain):
776                 if cond[0] == 'id':
777                     res_id = cond[2]
778             # Record id not found: issue
779             if res_id <= 0:
780                 raise osv.except_osv(_('Record id not found'), _('The share engine has not been able to fetch a record_id for your invitation.'))
781             self.pool.get(model.model).message_subscribe(cr, uid, [res_id], new_ids + existing_ids, context=context)
782             # self.send_invite_email(cr, uid, wizard_data, context=context)
783             # self.send_invite_note(cr, uid, model.model, res_id, wizard_data, context=context)
784         
785         # CLOSE
786         #  A. Not invite: as before
787         #  B. Invite: skip summary screen, get back to the record
788         
789         # A.
790         if not wizard_data.invite:
791             dummy, step2_form_view_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'share', 'share_step2_form')
792             return {
793                 'name': _('Shared access created!'),
794                 'view_type': 'form',
795                 'view_mode': 'form',
796                 'res_model': 'share.wizard',
797                 'view_id': False,
798                 'res_id': ids[0],
799                 'views': [(step2_form_view_id, 'form'), (False, 'tree'), (False, 'calendar'), (False, 'graph')],
800                 'type': 'ir.actions.act_window',
801                 'target': 'new'
802             }
803         # B.
804         else:
805             return {
806                 'view_type': 'form',
807                 'view_mode': 'form',
808                 'res_model': model.model,
809                 'view_id': False,
810                 'res_id': res_id,
811                 'views': [(False, 'form'), (False, 'tree'), (False, 'calendar'), (False, 'graph')],
812                 'type': 'ir.actions.act_window',
813             }
814             
815
816     def send_invite_note(self, cr, uid, model_name, res_id, wizard_data, context=None):
817         subject = _('Invitation')
818         body = 'has been <b>shared</b> with'
819         tmp_idx = 0
820         for result_line in wizard_data.result_line_ids:
821             body += ' @%s' % (result_line.user_id.login)
822             if tmp_idx < len(wizard_data.result_line_ids)-2:
823                 body += ','
824             elif tmp_idx == len(wizard_data.result_line_ids)-2:
825                 body += ' and'
826         body += '.'
827         return self.pool.get(model_name).message_post(cr, uid, [res_id], body=body, context=context)
828     
829     def send_invite_email(self, cr, uid, wizard_data, context=None):
830         # TDE Note: not updated because will disappear
831         message_obj = self.pool.get('mail.message')
832         notification_obj = self.pool.get('mail.notification')
833         user = self.pool.get('res.users').browse(cr, UID_ROOT, uid)
834         if not user.email:
835             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.'))
836         
837         # TODO: also send an HTML version of this mail
838         for result_line in wizard_data.result_line_ids:
839             email_to = result_line.user_id.email
840             if not email_to:
841                 continue
842             subject = _('Invitation to collaborate about %s') % (wizard_data.record_name)
843             body = _("Hello,\n\n")
844             body += _("I have shared %s (%s) with you!\n\n") % (wizard_data.record_name, wizard_data.name)
845             if wizard_data.message:
846                 body += "%s\n\n" % (wizard_data.message)
847             if result_line.newly_created:
848                 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)
849                 body += _("These are your credentials to access this protected area:\n")
850                 body += "%s: %s" % (_("Username"), result_line.user_id.login) + "\n"
851                 body += "%s: %s" % (_("Password"), result_line.password) + "\n"
852                 body += "%s: %s" % (_("Database"), cr.dbname) + "\n"
853             body += _("The documents have been automatically added to your subscriptions.\n\n")
854             body += '%s\n\n' % ((user.signature or ''))
855             body += "--\n"
856             body += _("OpenERP is a powerful and user-friendly suite of Business Applications (CRM, Sales, HR, etc.)\n"
857                       "It is open source and can be found on http://www.openerp.com.")
858             msg_id = message_obj.schedule_with_attach(cr, uid, user.email, [email_to], subject, body, model='', context=context)
859             notification_obj.create(cr, uid, {'user_id': result_line.user_id.id, 'message_id': msg_id}, context=context)
860     
861     def send_emails(self, cr, uid, wizard_data, context=None):
862         _logger.info('Sending share notifications by email...')
863         mail_mail = self.pool.get('mail.mail')
864         user = self.pool.get('res.users').browse(cr, UID_ROOT, uid)
865         if not user.email:
866             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.'))
867         
868         # TODO: also send an HTML version of this mail
869         mail_ids = []
870         for result_line in wizard_data.result_line_ids:
871             email_to = result_line.user_id.email
872             if not email_to:
873                 continue
874             subject = wizard_data.name
875             body = _("Hello,\n\n")
876             body += _("I've shared %s with you!\n\n") % wizard_data.name
877             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)
878             if wizard_data.message:
879                 body += '%s\n\n' % (wizard_data.message)
880             if result_line.newly_created:
881                 body += _("These are your credentials to access this protected area:\n")
882                 body += "%s: %s\n" % (_("Username"), result_line.user_id.login)
883                 body += "%s: %s\n" % (_("Password"), result_line.password)
884                 body += "%s: %s\n" % (_("Database"), cr.dbname)
885             else:
886                 body += _("The documents have been automatically added to your current OpenERP documents.\n")
887                 body += _("You may use your current login (%s) and password to view them.\n") % result_line.user_id.login
888             body += "\n\n%s\n\n" % ( (user.signature or '') )
889             body += "--\n"
890             body += _("OpenERP is a powerful and user-friendly suite of Business Applications (CRM, Sales, HR, etc.)\n"
891                       "It is open source and can be found on http://www.openerp.com.")
892             mail_ids.append(mail_mail.create(cr, uid, {
893                     'email_from': user.email,
894                     'email_to': email_to,
895                     'subject': subject,
896                     'body_html': '<pre>%s</pre>' % body}, context=context))
897         # force direct delivery, as users expect instant notification
898         mail_mail.send(cr, uid, mail_ids, context=context)
899         _logger.info('%d share notification(s) sent.', len(mail_ids))
900
901     def onchange_embed_options(self, cr, uid, ids, opt_title, opt_search, context=None):
902         wizard = self.browse(cr, uid, ids[0], context)
903         options = dict(title=opt_title, search=opt_search)
904         return {'value': {'embed_code': self._generate_embedded_code(wizard, options)}}
905
906 share_wizard()
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: