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 api
30 from openerp import tools
31 from openerp.osv import fields, osv
32 from openerp.osv import expression
33 from openerp.tools.translate import _
34 from openerp.tools.safe_eval import safe_eval
36 _logger = logging.getLogger(__name__)
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',)
43 # Pseudo-domain to represent an empty filter, constructed using
44 # osv.expression's DUMMY_LEAF
45 DOMAIN_ALL = [(1, '=', 1)]
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))
52 class share_wizard(osv.TransientModel):
53 _name = 'share.wizard'
54 _description = 'Share Wizard'
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 _().
61 raise osv.except_osv(_('Sharing access cannot be created.'), error_message)
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)
67 model, group_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, module, group_xml_id)
70 return group_id in self.pool.get('res.users').read(cr, uid, [uid], ['groups_id'], context=context)[0]['groups_id']
72 def has_share(self, cr, uid, unused_param, context=None):
73 return self.has_group(cr, uid, module='base', group_xml_id='group_no_one', context=context)
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')), ]
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)
86 @api.cr_uid_ids_context
87 def share_url_template(self, cr, uid, _ids, context=None):
88 # NOTE: take _ids in parameter to allow usage through browse_record objects
89 base_url = self.pool.get('ir.config_parameter').get_param(cr, uid, 'web.base.url', default='', context=context)
91 base_url += '/login?db=%(dbname)s&login=%(login)s&key=%(password)s'
92 extra = context and context.get('share_url_template_extra_arguments')
94 base_url += '&' + '&'.join('%s=%%(%s)s' % (x,x) for x in extra)
95 hash_ = context and context.get('share_url_template_hash_arguments')
97 base_url += '#' + '&'.join('%s=%%(%s)s' % (x,x) for x in hash_)
100 def _share_root_url(self, cr, uid, ids, _fieldname, _args, context=None):
101 result = dict.fromkeys(ids, '')
102 data = dict(dbname=cr.dbname, login='', password='')
103 for this in self.browse(cr, uid, ids, context=context):
104 result[this.id] = this.share_url_template() % data
107 def _generate_embedded_code(self, wizard, options=None):
108 cr, uid, context = wizard.env.args
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
117 js_options['display_title'] = False
119 js_options['search_view'] = True
121 js_options_str = (', ' + simplejson.dumps(js_options)) if js_options else ''
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]
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);
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,
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)
147 def _embed_url(self, cr, uid, ids, _fn, _args, context=None):
150 result = dict.fromkeys(ids, '')
151 for this in self.browse(cr, uid, ids, context=context):
152 if this.result_line_ids:
153 ctx = dict(context, share_url_template_hash_arguments=['action'])
154 user = this.result_line_ids[0]
155 data = dict(dbname=cr.dbname, login=user.login, password=user.password, action=this.action_id.id)
156 result[this.id] = this.share_url_template(context=ctx) % data
161 'action_id': fields.many2one('ir.actions.act_window', 'Action to share', required=True,
162 help="The action that opens the screen containing the data you wish to share."),
163 'view_type': fields.char('Current View Type', required=True),
164 'domain': fields.char('Domain', help="Optional domain for further data filtering"),
165 'user_type': fields.selection(lambda s, *a, **k: s._user_type_selection(*a, **k),'Sharing method', required=True,
166 help="Select the type of user(s) you would like to share data with."),
167 'new_users': fields.text("Emails"),
168 'email_1': fields.char('New user email', size=64),
169 'email_2': fields.char('New user email', size=64),
170 'email_3': fields.char('New user email', size=64),
171 'invite': fields.boolean('Invite users to OpenSocial record'),
172 'access_mode': fields.selection([('readonly','Can view'),('readwrite','Can edit')],'Access Mode', required=True,
173 help="Access rights to be granted on the shared documents."),
174 'result_line_ids': fields.one2many('share.wizard.result.line', 'share_wizard_id', 'Summary', readonly=True),
175 'share_root_url': fields.function(_share_root_url, string='Share Access URL', type='char', readonly=True,
176 help='Main access page for users that are granted shared access'),
177 'name': fields.char('Share Title', required=True, help="Title for the share (displayed to users as menu and shortcut name)"),
178 'record_name': fields.char('Record name', help="Name of the shared record, if sharing a precise record"),
179 'message': fields.text("Personal Message", help="An optional personal message, to be included in the email notification."),
180 'embed_code': fields.function(_embed_code, type='text', string='Code',
181 help="Embed this code in your documents to provide a link to the "\
183 'embed_option_title': fields.boolean('Display title'),
184 'embed_option_search': fields.boolean('Display search view'),
185 'embed_url': fields.function(_embed_url, string='Share URL', size=512, type='char', readonly=True),
189 'user_type' : 'embedded',
191 'domain': lambda self, cr, uid, context, *a: context.get('domain', '[]'),
192 'action_id': lambda self, cr, uid, context, *a: context.get('action_id'),
193 'access_mode': 'readwrite',
194 'embed_option_title': True,
195 'embed_option_search': True,
198 def has_email(self, cr, uid, context=None):
199 return bool(self.pool.get('res.users').browse(cr, uid, uid, context=context).email)
201 def go_step_1(self, cr, uid, ids, context=None):
202 wizard_data = self.browse(cr,uid,ids,context)[0]
203 if wizard_data.user_type == 'emails' and not self.has_email(cr, uid, context=context):
204 raise osv.except_osv(_('No email address configured'),
205 _('You must configure your email address in the user preferences before using the Share button.'))
206 model, res_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'share', 'action_share_wizard_step1')
207 action = self.pool[model].read(cr, uid, [res_id], context=context)[0]
208 action['res_id'] = ids[0]
209 action.pop('context', '')
212 def _create_share_group(self, cr, uid, wizard_data, context=None):
213 group_obj = self.pool.get('res.groups')
214 share_group_name = '%s: %s (%d-%s)' %('Shared', wizard_data.name, uid, time.time())
215 # create share group without putting admin in it
216 return group_obj.create(cr, UID_ROOT, {'name': share_group_name, 'share': True}, {'noadmin': True})
218 def _create_new_share_users(self, cr, uid, wizard_data, group_id, context=None):
219 """Create one new res.users record for each email address provided in
220 wizard_data.new_users, ignoring already existing users.
221 Populates wizard_data.result_line_ids with one new line for
222 each user (existing or not). New users will also have a value
223 for the password field, so they can receive it by email.
224 Returns the ids of the created users, and the ids of the
225 ignored, existing ones."""
226 context = dict(context or {})
227 user_obj = self.pool.get('res.users')
228 current_user = user_obj.browse(cr, UID_ROOT, uid, context=context)
229 # modify context to disable shortcuts when creating share users
230 context['noshortcut'] = True
231 context['no_reset_password'] = True
234 if wizard_data.user_type == 'emails':
235 # get new user list from email data
236 new_users = (wizard_data.new_users or '').split('\n')
237 new_users += [wizard_data.email_1 or '', wizard_data.email_2 or '', wizard_data.email_3 or '']
238 for new_user in new_users:
240 new_user = new_user.strip()
241 if not new_user: continue
242 # Ignore the user if it already exists.
243 if not wizard_data.invite:
244 existing = user_obj.search(cr, UID_ROOT, [('login', '=', new_user)])
246 existing = user_obj.search(cr, UID_ROOT, [('email', '=', new_user)])
247 existing_ids.extend(existing)
249 new_line = { 'user_id': existing[0],
250 'newly_created': False}
251 wizard_data.write({'result_line_ids': [(0,0,new_line)]})
253 new_pass = generate_random_pass()
254 user_id = user_obj.create(cr, UID_ROOT, {
256 'password': new_pass,
259 'groups_id': [(6,0,[group_id])],
260 'company_id': current_user.company_id.id,
261 'company_ids': [(6, 0, [current_user.company_id.id])],
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)
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, {
274 'password': new_pass,
276 'groups_id': [(6,0,[group_id])],
277 'company_id': current_user.company_id.id,
278 'company_ids': [(6, 0, [current_user.company_id.id])],
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)
286 return created_ids, existing_ids
288 def _create_action(self, cr, uid, values, context=None):
291 new_context = context.copy()
293 if key.startswith('default_'):
295 action_id = self.pool.get('ir.actions.act_window').create(cr, UID_ROOT, values, new_context)
298 def _cleanup_action_context(self, context_str, user_id):
299 """Returns a dict representing the context_str evaluated (safe_eval) as
300 a dict where items that are not useful for shared actions
301 have been removed. If the evaluation of context_str as a
302 dict fails, context_str is returned unaltered.
304 :param user_id: the integer uid to be passed as 'uid' in the
310 context = safe_eval(context_str, tools.UnquoteEvalContext(), nocopy=True)
311 result = dict(context)
313 # Remove all context keys that seem to toggle default
314 # filters based on the current user, as it makes no sense
315 # for shared users, who would not see any data by default.
316 if key and key.startswith('search_default_') and 'user_id' in key:
319 # Note: must catch all exceptions, as UnquoteEvalContext may cause many
320 # different exceptions, as it shadows builtins.
321 _logger.debug("Failed to cleanup action context as it does not parse server-side", exc_info=True)
325 def _shared_action_def(self, cr, uid, wizard_data, context=None):
326 copied_action = wizard_data.action_id
328 if wizard_data.access_mode == 'readonly':
329 view_mode = wizard_data.view_type
330 view_id = copied_action.view_id.id if copied_action.view_id.type == wizard_data.view_type else False
332 view_mode = copied_action.view_mode
333 view_id = copied_action.view_id.id
337 'name': wizard_data.name,
338 'domain': copied_action.domain,
339 'context': self._cleanup_action_context(wizard_data.action_id.context, uid),
340 'res_model': copied_action.res_model,
341 'view_mode': view_mode,
342 'view_type': copied_action.view_type,
343 'search_view_id': copied_action.search_view_id.id if wizard_data.access_mode != 'readonly' else False,
347 if copied_action.view_ids:
348 action_def['view_ids'] = [(0,0,{'sequence': x.sequence,
349 'view_mode': x.view_mode,
350 'view_id': x.view_id.id })
351 for x in copied_action.view_ids
352 if (wizard_data.access_mode != 'readonly' or x.view_mode == wizard_data.view_type)
356 def _setup_action_and_shortcut(self, cr, uid, wizard_data, user_ids, make_home, context=None):
357 """Create a shortcut to reach the shared data, as well as the corresponding action, for
358 each user in ``user_ids``, and assign it as their home action if ``make_home`` is True.
359 Meant to be overridden for special cases.
361 values = self._shared_action_def(cr, uid, wizard_data, context=None)
362 user_obj = self.pool.get('res.users')
363 for user_id in user_ids:
364 action_id = self._create_action(cr, user_id, values)
366 # We do this only for new share users, as existing ones already have their initial home
367 # action. Resetting to the default menu does not work well as the menu is rather empty
368 # and does not contain the shortcuts in most cases.
369 user_obj.write(cr, UID_ROOT, [user_id], {'action_id': action_id})
371 def _get_recursive_relations(self, cr, uid, model, ttypes, relation_fields=None, suffix=None, context=None):
372 """Returns list of tuples representing recursive relationships of type ``ttypes`` starting from
373 model with ID ``model_id``.
375 :param model: browsable model to start loading relationships from
376 :param ttypes: list of relationship types to follow (e.g: ['one2many','many2many'])
377 :param relation_fields: list of previously followed relationship tuples - to avoid duplicates
379 :param suffix: optional suffix to append to the field path to reach the main object
381 if relation_fields is None:
383 local_rel_fields = []
384 models = [x[1].model for x in relation_fields]
385 model_obj = self.pool.get('ir.model')
386 model_osv = self.pool[model.model]
387 for colinfo in model_osv._all_columns.itervalues():
388 coldef = colinfo.column
389 coltype = coldef._type
390 relation_field = None
391 if coltype in ttypes and colinfo.column._obj not in models:
392 relation_model_id = model_obj.search(cr, UID_ROOT, [('model','=',coldef._obj)])[0]
393 relation_model_browse = model_obj.browse(cr, UID_ROOT, relation_model_id, context=context)
394 relation_osv = self.pool[coldef._obj]
395 #skip virtual one2many fields (related, ...) as there is no reverse relationship
396 if coltype == 'one2many' and hasattr(coldef, '_fields_id'):
397 # don't record reverse path if it's not a real m2o (that happens, but rarely)
398 dest_model_ci = relation_osv._all_columns
399 reverse_rel = coldef._fields_id
400 if reverse_rel in dest_model_ci and dest_model_ci[reverse_rel].column._type == 'many2one':
401 relation_field = ('%s.%s'%(reverse_rel, suffix)) if suffix else reverse_rel
402 local_rel_fields.append((relation_field, relation_model_browse))
403 for parent in relation_osv._inherits:
404 if parent not in models:
405 parent_model = self.pool[parent]
406 parent_colinfos = parent_model._all_columns
407 parent_model_browse = model_obj.browse(cr, UID_ROOT,
408 model_obj.search(cr, UID_ROOT, [('model','=',parent)]))[0]
409 if relation_field and coldef._fields_id in parent_colinfos:
410 # inverse relationship is available in the parent
411 local_rel_fields.append((relation_field, parent_model_browse))
413 # TODO: can we setup a proper rule to restrict inherited models
414 # in case the parent does not contain the reverse m2o?
415 local_rel_fields.append((None, parent_model_browse))
416 if relation_model_id != model.id and coltype in ['one2many', 'many2many']:
417 local_rel_fields += self._get_recursive_relations(cr, uid, relation_model_browse,
418 [coltype], relation_fields + local_rel_fields, suffix=relation_field, context=context)
419 return local_rel_fields
421 def _get_relationship_classes(self, cr, uid, model, context=None):
422 """Computes the *relationship classes* reachable from the given
423 model. The 4 relationship classes are:
424 - [obj0]: the given model itself (and its parents via _inherits, if any)
425 - [obj1]: obj0 and all other models recursively accessible from
426 obj0 via one2many relationships
427 - [obj2]: obj0 and all other models recursively accessible from
428 obj0 via one2many and many2many relationships
429 - [obj3]: all models recursively accessible from obj1 via many2one
432 Each class is returned as a list of pairs [(field,model_browse)], where
433 ``model`` is the browse_record of a reachable ir.model, and ``field`` is
434 the dot-notation reverse relationship path coming from that model to obj0,
435 or None if there is no reverse path.
437 :return: ([obj0], [obj1], [obj2], [obj3])
439 # obj0 class and its parents
440 obj0 = [(None, model)]
441 model_obj = self.pool[model.model]
442 ir_model_obj = self.pool.get('ir.model')
443 for parent in model_obj._inherits:
444 parent_model_browse = ir_model_obj.browse(cr, UID_ROOT,
445 ir_model_obj.search(cr, UID_ROOT, [('model','=',parent)]))[0]
446 obj0 += [(None, parent_model_browse)]
448 obj1 = self._get_recursive_relations(cr, uid, model, ['one2many'], relation_fields=obj0, context=context)
449 obj2 = self._get_recursive_relations(cr, uid, model, ['one2many', 'many2many'], relation_fields=obj0, context=context)
450 obj3 = self._get_recursive_relations(cr, uid, model, ['many2one'], relation_fields=obj0, context=context)
451 for dummy, model in obj1:
452 obj3 += self._get_recursive_relations(cr, uid, model, ['many2one'], relation_fields=obj0, context=context)
453 return obj0, obj1, obj2, obj3
455 def _get_access_map_for_groups_and_models(self, cr, uid, group_ids, model_ids, context=None):
456 model_access_obj = self.pool.get('ir.model.access')
457 user_right_ids = model_access_obj.search(cr, uid,
458 [('group_id', 'in', group_ids), ('model_id', 'in', model_ids)],
460 user_access_matrix = {}
462 for access_right in model_access_obj.browse(cr, uid, user_right_ids, context=context):
463 access_line = user_access_matrix.setdefault(access_right.model_id.model, set())
464 for perm in FULL_ACCESS:
465 if getattr(access_right, perm, 0):
466 access_line.add(perm)
467 return user_access_matrix
469 def _add_access_rights_for_share_group(self, cr, uid, group_id, mode, fields_relations, context=None):
470 """Adds access rights to group_id on object models referenced in ``fields_relations``,
471 intersecting with access rights of current user to avoid granting too much rights
473 model_access_obj = self.pool.get('ir.model.access')
474 user_obj = self.pool.get('res.users')
475 target_model_ids = [x[1].id for x in fields_relations]
476 perms_to_add = (mode == 'readonly') and READ_ONLY_ACCESS or READ_WRITE_ACCESS
477 current_user = user_obj.browse(cr, uid, uid, context=context)
479 current_user_access_map = self._get_access_map_for_groups_and_models(cr, uid,
480 [x.id for x in current_user.groups_id], target_model_ids, context=context)
481 group_access_map = self._get_access_map_for_groups_and_models(cr, uid,
482 [group_id], target_model_ids, context=context)
483 _logger.debug("Current user access matrix: %r", current_user_access_map)
484 _logger.debug("New group current access matrix: %r", group_access_map)
486 # Create required rights if allowed by current user rights and not
488 for dummy, model in fields_relations:
489 # mail.message is transversal: it should not received directly the access rights
490 if model.model in ['mail.message']: continue
492 'name': _('Copied access for sharing'),
493 'group_id': group_id,
494 'model_id': model.id,
496 current_user_access_line = current_user_access_map.get(model.model,set())
497 existing_group_access_line = group_access_map.get(model.model,set())
498 need_creation = False
499 for perm in perms_to_add:
500 if perm in current_user_access_line \
501 and perm not in existing_group_access_line:
502 values.update({perm:True})
503 group_access_map.setdefault(model.model, set()).add(perm)
506 model_access_obj.create(cr, UID_ROOT, values)
507 _logger.debug("Creating access right for model %s with values: %r", model.model, values)
509 def _link_or_copy_current_user_rules(self, cr, current_user, group_id, fields_relations, context=None):
510 rule_obj = self.pool.get('ir.rule')
512 for group in current_user.groups_id:
513 for dummy, model in fields_relations:
514 for rule in group.rule_groups:
515 if rule.id in rules_done:
517 rules_done.add(rule.id)
518 if rule.model_id.id == model.id:
519 if 'user.' in rule.domain_force:
520 # Above pattern means there is likely a condition
521 # specific to current user, so we must copy the rule using
522 # the evaluated version of the domain.
523 # And it's better to copy one time too much than too few
524 rule_obj.copy(cr, UID_ROOT, rule.id, default={
525 'name': '%s %s' %(rule.name, _('(Copy for sharing)')),
526 'groups': [(6,0,[group_id])],
527 'domain_force': rule.domain, # evaluated version!
529 _logger.debug("Copying rule %s (%s) on model %s with domain: %s", rule.name, rule.id, model.model, rule.domain_force)
531 # otherwise we can simply link the rule to keep it dynamic
532 rule_obj.write(cr, SUPERUSER_ID, [rule.id], {
533 'groups': [(4,group_id)]
535 _logger.debug("Linking rule %s (%s) on model %s with domain: %s", rule.name, rule.id, model.model, rule.domain_force)
537 def _check_personal_rule_or_duplicate(self, cr, group_id, rule, context=None):
538 """Verifies that the given rule only belongs to the given group_id, otherwise
539 duplicate it for the current group, and unlink the previous one.
540 The duplicated rule has the original domain copied verbatim, without
542 Returns the final rule to use (browse_record), either the original one if it
543 only belongs to this group, or the copy."""
544 if len(rule.groups) == 1:
546 # duplicate it first:
547 rule_obj = self.pool.get('ir.rule')
548 new_id = rule_obj.copy(cr, UID_ROOT, rule.id,
550 'name': '%s %s' %(rule.name, _('(Duplicated for modified sharing permissions)')),
551 'groups': [(6,0,[group_id])],
552 'domain_force': rule.domain_force, # non evaluated!
554 _logger.debug("Duplicating rule %s (%s) (domain: %s) for modified access ", rule.name, rule.id, rule.domain_force)
555 # then disconnect from group_id:
556 rule.write({'groups':[(3,group_id)]}) # disconnects, does not delete!
557 return rule_obj.browse(cr, UID_ROOT, new_id, context=context)
559 def _create_or_combine_sharing_rule(self, cr, current_user, wizard_data, group_id, model_id, domain, restrict=False, rule_name=None, context=None):
560 """Add a new ir.rule entry for model_id and domain on the target group_id.
561 If ``restrict`` is True, instead of adding a rule, the domain is
562 combined with AND operator with all existing rules in the group, to implement
563 an additional restriction (as of 6.1, multiple rules in the same group are
564 OR'ed by default, so a restriction must alter all existing rules)
566 This is necessary because the personal rules of the user that is sharing
567 are first copied to the new share group. Afterwards the filters used for
568 sharing are applied as an additional layer of rules, which are likely to
569 apply to the same model. The default rule algorithm would OR them (as of 6.1),
570 which would result in a combined set of permission that could be larger
571 than those of the user that is sharing! Hence we must forcefully AND the
573 One possibly undesirable effect can appear when sharing with a
574 pre-existing group, in which case altering pre-existing rules would not
575 be desired. This is addressed in the portal module.
577 if rule_name is None:
578 rule_name = _('Sharing filter created by user %s (%s) for group %s') % \
579 (current_user.name, current_user.login, group_id)
580 rule_obj = self.pool.get('ir.rule')
581 rule_ids = rule_obj.search(cr, UID_ROOT, [('groups', 'in', group_id), ('model_id', '=', model_id)])
583 for rule in rule_obj.browse(cr, UID_ROOT, rule_ids, context=context):
584 if rule.domain_force == domain:
585 # don't create it twice!
589 _logger.debug("Ignoring sharing rule on model %s with domain: %s the same rule exists already", model_id, domain)
592 # restricting existing rules is done by adding the clause
593 # with an AND, but we can't alter the rule if it belongs to
594 # other groups, so we duplicate if needed
595 rule = self._check_personal_rule_or_duplicate(cr, group_id, rule, context=context)
596 eval_ctx = rule_obj._eval_context_for_combinations()
597 org_domain = expression.normalize_domain(eval(rule.domain_force, eval_ctx))
598 new_clause = expression.normalize_domain(eval(domain, eval_ctx))
599 combined_domain = expression.AND([new_clause, org_domain])
600 rule.write({'domain_force': combined_domain, 'name': rule.name + _('(Modified)')})
601 _logger.debug("Combining sharing rule %s on model %s with domain: %s", rule.id, model_id, domain)
602 if not rule_ids or not restrict:
603 # Adding the new rule in the group is ok for normal cases, because rules
604 # in the same group and for the same model will be combined with OR
605 # (as of v6.1), so the desired effect is achieved.
606 rule_obj.create(cr, UID_ROOT, {
608 'model_id': model_id,
609 'domain_force': domain,
610 'groups': [(4,group_id)]
612 _logger.debug("Created sharing rule on model %s with domain: %s", model_id, domain)
614 def _create_indirect_sharing_rules(self, cr, current_user, wizard_data, group_id, fields_relations, context=None):
615 rule_name = _('Indirect sharing filter created by user %s (%s) for group %s') % \
616 (current_user.name, current_user.login, group_id)
618 domain = safe_eval(wizard_data.domain)
620 for rel_field, model in fields_relations:
621 # mail.message is transversal: it should not received directly the access rights
622 if model.model in ['mail.message']: continue
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)
635 _logger.exception('Failed to create share access')
636 raise osv.except_osv(_('Sharing access cannot be created.'),
637 _('Sorry, the current screen and filter you are trying to share are not supported at the moment.\nYou may want to try a simpler filter.'))
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.'),
643 self._assert(self.has_share(cr, uid, wizard_data, context=context),
644 _('You must be a member of the Technical group to use the share wizard.'),
646 if wizard_data.user_type == 'emails':
647 self._assert((wizard_data.new_users or wizard_data.email_1 or wizard_data.email_2 or wizard_data.email_3),
648 _('Please indicate the emails of the persons to share with, one per line.'),
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.
655 :return: a tuple composed of the new group id (to which the shared access should be granted),
656 the ids of the new share users that have been created and the ids of the existing share users
658 group_id = self._create_share_group(cr, uid, wizard_data, context=context)
659 # First create any missing user, based on the email addresses provided
660 new_ids, existing_ids = self._create_new_share_users(cr, uid, wizard_data, group_id, context=context)
661 # Finally, setup the new action and shortcut for the users.
663 # existing users still need to join the new group
664 self.pool.get('res.users').write(cr, UID_ROOT, existing_ids, {
665 'groups_id': [(4,group_id)],
667 # existing user don't need their home action replaced, only a new shortcut
668 self._setup_action_and_shortcut(cr, uid, wizard_data, existing_ids, make_home=False, context=context)
670 # new users need a new shortcut AND a home action
671 self._setup_action_and_shortcut(cr, uid, wizard_data, new_ids, make_home=True, context=context)
672 return group_id, new_ids, existing_ids
674 def go_step_2(self, cr, uid, ids, context=None):
675 wizard_data = self.browse(cr, uid, ids[0], context=context)
676 self._check_preconditions(cr, uid, wizard_data, context=context)
678 # Create shared group and users
679 group_id, new_ids, existing_ids = self._create_share_users_group(cr, uid, wizard_data, context=context)
681 current_user = self.pool.get('res.users').browse(cr, uid, uid, context=context)
683 model_obj = self.pool.get('ir.model')
684 model_id = model_obj.search(cr, uid, [('model','=', wizard_data.action_id.res_model)])[0]
685 model = model_obj.browse(cr, uid, model_id, context=context)
688 # We have several classes of objects that should receive different access rights:
690 # - [obj0] be the target model itself (and its parents via _inherits, if any)
691 # - [obj1] be the target model and all other models recursively accessible from
692 # obj0 via one2many relationships
693 # - [obj2] be the target model and all other models recursively accessible from
694 # obj0 via one2many and many2many relationships
695 # - [obj3] be all models recursively accessible from obj1 via many2one relationships
696 # (currently not used)
697 obj0, obj1, obj2, obj3 = self._get_relationship_classes(cr, uid, model, context=context)
698 mode = wizard_data.access_mode
700 # Add access to [obj0] and [obj1] according to chosen mode
701 self._add_access_rights_for_share_group(cr, uid, group_id, mode, obj0, context=context)
702 self._add_access_rights_for_share_group(cr, uid, group_id, mode, obj1, context=context)
704 # Add read-only access (always) to [obj2]
705 self._add_access_rights_for_share_group(cr, uid, group_id, 'readonly', obj2, context=context)
708 # A. On [obj0], [obj1], [obj2]: add all rules from all groups of
709 # the user that is sharing
710 # Warning: rules must be copied instead of linked if they contain a reference
711 # to uid or if the rule is shared with other groups (and it must be replaced correctly)
712 # B. On [obj0]: 1 rule with domain of shared action
713 # C. For each model in [obj1]: 1 rule in the form:
714 # many2one_rel.domain_of_obj0
715 # where many2one_rel is the many2one used in the definition of the
716 # one2many, and domain_of_obj0 is the sharing domain
717 # For example if [obj0] is project.project with a domain of
718 # ['id', 'in', [1,2]]
719 # then we will have project.task in [obj1] and we need to create this
720 # ir.rule on project.task:
721 # ['project_id.id', 'in', [1,2]]
724 all_relations = obj0 + obj1 + obj2
725 self._link_or_copy_current_user_rules(cr, current_user, group_id, all_relations, context=context)
727 main_domain = wizard_data.domain if wizard_data.domain != '[]' else str(DOMAIN_ALL)
728 self._create_or_combine_sharing_rule(cr, current_user, wizard_data,
729 group_id, model_id=model.id, domain=main_domain,
730 restrict=True, context=context)
732 self._create_indirect_sharing_rules(cr, current_user, wizard_data, group_id, obj1, context=context)
734 # refresh wizard_data
735 wizard_data = self.browse(cr, uid, ids[0], context=context)
737 # EMAILS AND NOTIFICATIONS
738 # A. Not invite: as before
739 # -> send emails to destination users
740 # B. Invite (OpenSocial)
741 # -> subscribe all users (existing and new) to the record
742 # -> send a notification with a summary to the current record
743 # -> send a notification to all users; users allowing to receive
744 # emails in preferences will receive it
745 # new users by default receive all notifications by email
748 if not wizard_data.invite:
749 self.send_emails(cr, uid, wizard_data, context=context)
752 # Invite (OpenSocial): automatically subscribe users to the record
754 for cond in safe_eval(main_domain):
757 # Record id not found: issue
759 raise osv.except_osv(_('Record id not found'), _('The share engine has not been able to fetch a record_id for your invitation.'))
760 self.pool[model.model].message_subscribe(cr, uid, [res_id], new_ids + existing_ids, context=context)
761 # self.send_invite_email(cr, uid, wizard_data, context=context)
762 # self.send_invite_note(cr, uid, model.model, res_id, wizard_data, context=context)
765 # A. Not invite: as before
766 # B. Invite: skip summary screen, get back to the record
769 if not wizard_data.invite:
770 dummy, step2_form_view_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'share', 'share_step2_form')
772 'name': _('Shared access created!'),
775 'res_model': 'share.wizard',
778 'views': [(step2_form_view_id, 'form'), (False, 'tree'), (False, 'calendar'), (False, 'graph')],
779 'type': 'ir.actions.act_window',
787 'res_model': model.model,
790 'views': [(False, 'form'), (False, 'tree'), (False, 'calendar'), (False, 'graph')],
791 'type': 'ir.actions.act_window',
795 def send_invite_note(self, cr, uid, model_name, res_id, wizard_data, context=None):
796 subject = _('Invitation')
797 body = 'has been <b>shared</b> with'
799 for result_line in wizard_data.result_line_ids:
800 body += ' @%s' % (result_line.user_id.login)
801 if tmp_idx < len(wizard_data.result_line_ids)-2:
803 elif tmp_idx == len(wizard_data.result_line_ids)-2:
806 return self.pool[model_name].message_post(cr, uid, [res_id], body=body, context=context)
808 def send_invite_email(self, cr, uid, wizard_data, context=None):
809 # TDE Note: not updated because will disappear
810 message_obj = self.pool.get('mail.message')
811 notification_obj = self.pool.get('mail.notification')
812 user = self.pool.get('res.users').browse(cr, UID_ROOT, uid)
814 raise osv.except_osv(_('Email Required'), _('The current user must have an email address configured in User Preferences to be able to send outgoing emails.'))
816 # TODO: also send an HTML version of this mail
817 for result_line in wizard_data.result_line_ids:
818 email_to = result_line.user_id.email
821 subject = _('Invitation to collaborate about %s') % (wizard_data.record_name)
822 body = _("Hello,\n\n")
823 body += _("I have shared %s (%s) with you!\n\n") % (wizard_data.record_name, wizard_data.name)
824 if wizard_data.message:
825 body += "%s\n\n" % (wizard_data.message)
826 if result_line.newly_created:
827 body += _("The documents are not attached, you can view them online directly on my Odoo server at:\n %s\n\n") % (result_line.share_url)
828 body += _("These are your credentials to access this protected area:\n")
829 body += "%s: %s" % (_("Username"), result_line.user_id.login) + "\n"
830 body += "%s: %s" % (_("Password"), result_line.password) + "\n"
831 body += "%s: %s" % (_("Database"), cr.dbname) + "\n"
832 body += _("The documents have been automatically added to your subscriptions.\n\n")
833 body += '%s\n\n' % ((user.signature or ''))
835 body += _("Odoo is a powerful and user-friendly suite of Business Applications (CRM, Sales, HR, etc.)\n"
836 "It is open source and can be found on http://www.openerp.com.")
837 msg_id = message_obj.schedule_with_attach(cr, uid, user.email, [email_to], subject, body, model='', context=context)
838 notification_obj.create(cr, uid, {'user_id': result_line.user_id.id, 'message_id': msg_id}, context=context)
840 def send_emails(self, cr, uid, wizard_data, context=None):
841 _logger.info('Sending share notifications by email...')
842 mail_mail = self.pool.get('mail.mail')
843 user = self.pool.get('res.users').browse(cr, UID_ROOT, uid)
845 raise osv.except_osv(_('Email Required'), _('The current user must have an email address configured in User Preferences to be able to send outgoing emails.'))
847 # TODO: also send an HTML version of this mail
849 for result_line in wizard_data.result_line_ids:
850 email_to = result_line.user_id.email
853 subject = wizard_data.name
854 body = _("Hello,\n\n")
855 body += _("I've shared %s with you!\n\n") % wizard_data.name
856 body += _("The documents are not attached, you can view them online directly on my Odoo server at:\n %s\n\n") % (result_line.share_url)
857 if wizard_data.message:
858 body += '%s\n\n' % (wizard_data.message)
859 if result_line.newly_created:
860 body += _("These are your credentials to access this protected area:\n")
861 body += "%s: %s\n" % (_("Username"), result_line.user_id.login)
862 body += "%s: %s\n" % (_("Password"), result_line.password)
863 body += "%s: %s\n" % (_("Database"), cr.dbname)
865 body += _("The documents have been automatically added to your current Odoo documents.\n")
866 body += _("You may use your current login (%s) and password to view them.\n") % result_line.user_id.login
867 body += "\n\n%s\n\n" % ( (user.signature or '') )
869 body += _("Odoo is a powerful and user-friendly suite of Business Applications (CRM, Sales, HR, etc.)\n"
870 "It is open source and can be found on http://www.openerp.com.")
871 mail_ids.append(mail_mail.create(cr, uid, {
872 'email_from': user.email,
873 'email_to': email_to,
875 'body_html': '<pre>%s</pre>' % body}, context=context))
876 # force direct delivery, as users expect instant notification
877 mail_mail.send(cr, uid, mail_ids, context=context)
878 _logger.info('%d share notification(s) sent.', len(mail_ids))
880 def onchange_embed_options(self, cr, uid, ids, opt_title, opt_search, context=None):
881 wizard = self.browse(cr, uid, ids[0], context)
882 options = dict(title=opt_title, search=opt_search)
883 return {'value': {'embed_code': self._generate_embedded_code(wizard, options)}}
886 class share_result_line(osv.osv_memory):
887 _name = 'share.wizard.result.line'
888 _rec_name = 'user_id'
891 def _share_url(self, cr, uid, ids, _fieldname, _args, context=None):
892 result = dict.fromkeys(ids, '')
893 for this in self.browse(cr, uid, ids, context=context):
894 data = dict(dbname=cr.dbname, login=this.login, password=this.password)
895 if this.share_wizard_id and this.share_wizard_id.action_id:
896 data['action_id'] = this.share_wizard_id.action_id.id
897 ctx = dict(context, share_url_template_hash_arguments=['action_id'])
898 result[this.id] = this.share_wizard_id.share_url_template(context=ctx) % data
902 'user_id': fields.many2one('res.users', required=True, readonly=True),
903 'login': fields.related('user_id', 'login', string='Login', type='char', size=64, required=True, readonly=True),
904 'password': fields.char('Password', size=64, readonly=True),
905 'share_url': fields.function(_share_url, string='Share URL', type='char', size=512),
906 'share_wizard_id': fields.many2one('share.wizard', 'Share Wizard', required=True, ondelete='cascade'),
907 'newly_created': fields.boolean('Newly created', readonly=True),
910 'newly_created': True,
913 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: