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