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