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