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