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