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 ##############################################################################
24 from urllib import quote_plus
26 from openerp import SUPERUSER_ID
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)['groups_id']
72 def has_share(self, cr, uid, unused_param, context=None):
73 return self.has_group(cr, uid, module='share', group_xml_id='group_share_user', 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 def share_url_template(self, cr, uid, _ids, context=None):
87 # NOTE: take _ids in parameter to allow usage through browse_record objects
88 base_url = self.pool.get('ir.config_parameter').get_param(cr, uid, 'web.base.url', default='', context=context)
90 base_url += '/login?db=%(dbname)s&login=%(login)s&key=%(password)s'
91 extra = context and context.get('share_url_template_extra_arguments')
93 base_url += '&' + '&'.join('%s=%%(%s)s' % (x,x) for x in extra)
94 hash_ = context and context.get('share_url_template_hash_arguments')
96 base_url += '#' + '&'.join('%s=%%(%s)s' % (x,x) for x in hash_)
99 def _share_root_url(self, cr, uid, ids, _fieldname, _args, context=None):
100 result = dict.fromkeys(ids, '')
101 data = dict(dbname=cr.dbname, login='', password='')
102 for this in self.browse(cr, uid, ids, context=context):
103 result[this.id] = this.share_url_template() % data
106 def _generate_embedded_code(self, wizard, options=None):
109 context = wizard._context
114 title = options['title'] if 'title' in options else wizard.embed_option_title
115 search = (options['search'] if 'search' in options else wizard.embed_option_search) if wizard.access_mode != 'readonly' else False
118 js_options['display_title'] = False
120 js_options['search_view'] = True
122 js_options_str = (', ' + simplejson.dumps(js_options)) if js_options else ''
124 base_url = self.pool.get('ir.config_parameter').get_param(cr, uid, 'web.base.url', default=None, context=context)
125 user = wizard.result_line_ids[0]
128 <script type="text/javascript" src="%(base_url)s/web/webclient/js"></script>
129 <script type="text/javascript">
130 new openerp.init(%(init)s).web.embed(%(server)s, %(dbname)s, %(login)s, %(password)s,%(action)d%(options)s);
132 'init': simplejson.dumps(openerp.conf.server_wide_modules),
133 'base_url': base_url or '',
134 'server': simplejson.dumps(base_url),
135 'dbname': simplejson.dumps(cr.dbname),
136 'login': simplejson.dumps(user.login),
137 'password': simplejson.dumps(user.password),
138 'action': user.user_id.action_id.id,
139 'options': js_options_str,
142 def _embed_code(self, cr, uid, ids, _fn, _args, context=None):
143 result = dict.fromkeys(ids, '')
144 for this in self.browse(cr, uid, ids, context=context):
145 result[this.id] = self._generate_embedded_code(this)
148 def _embed_url(self, cr, uid, ids, _fn, _args, context=None):
151 result = dict.fromkeys(ids, '')
152 for this in self.browse(cr, uid, ids, context=context):
153 if this.result_line_ids:
154 ctx = dict(context, share_url_template_hash_arguments=['action'])
155 user = this.result_line_ids[0]
156 data = dict(dbname=cr.dbname, login=user.login, password=user.password, action=this.action_id.id)
157 result[this.id] = this.share_url_template(context=ctx) % data
162 'action_id': fields.many2one('ir.actions.act_window', 'Action to share', required=True,
163 help="The action that opens the screen containing the data you wish to share."),
164 'view_type': fields.char('Current View Type', size=32, required=True),
165 'domain': fields.char('Domain', size=256, help="Optional domain for further data filtering"),
166 'user_type': fields.selection(lambda s, *a, **k: s._user_type_selection(*a, **k),'Sharing method', required=True,
167 help="Select the type of user(s) you would like to share data with."),
168 'new_users': fields.text("Emails"),
169 'email_1': fields.char('New user email', size=64),
170 'email_2': fields.char('New user email', size=64),
171 'email_3': fields.char('New user email', size=64),
172 'invite': fields.boolean('Invite users to OpenSocial record'),
173 'access_mode': fields.selection([('readonly','Can view'),('readwrite','Can edit')],'Access Mode', required=True,
174 help="Access rights to be granted on the shared documents."),
175 'result_line_ids': fields.one2many('share.wizard.result.line', 'share_wizard_id', 'Summary', readonly=True),
176 'share_root_url': fields.function(_share_root_url, string='Share Access URL', type='char', size=512, readonly=True,
177 help='Main access page for users that are granted shared access'),
178 'name': fields.char('Share Title', size=64, required=True, help="Title for the share (displayed to users as menu and shortcut name)"),
179 'record_name': fields.char('Record name', size=128, help="Name of the shared record, if sharing a precise record"),
180 'message': fields.text("Personal Message", help="An optional personal message, to be included in the email notification."),
181 'embed_code': fields.function(_embed_code, type='text', string='Code',
182 help="Embed this code in your documents to provide a link to the "\
184 'embed_option_title': fields.boolean('Display title'),
185 'embed_option_search': fields.boolean('Display search view'),
186 'embed_url': fields.function(_embed_url, string='Share URL', type='char', size=512, readonly=True),
190 'user_type' : 'embedded',
192 'domain': lambda self, cr, uid, context, *a: context.get('domain', '[]'),
193 'action_id': lambda self, cr, uid, context, *a: context.get('action_id'),
194 'access_mode': 'readwrite',
195 'embed_option_title': True,
196 'embed_option_search': True,
199 def has_email(self, cr, uid, context=None):
200 return bool(self.pool.get('res.users').browse(cr, uid, uid, context=context).email)
202 def go_step_1(self, cr, uid, ids, context=None):
203 wizard_data = self.browse(cr,uid,ids,context)[0]
204 if wizard_data.user_type == 'emails' and not self.has_email(cr, uid, context=context):
205 raise osv.except_osv(_('No email address configured'),
206 _('You must configure your email address in the user preferences before using the Share button.'))
207 model, res_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'share', 'action_share_wizard_step1')
208 action = self.pool.get(model).read(cr, uid, res_id, context=context)
209 action['res_id'] = ids[0]
210 action.pop('context', '')
213 def _create_share_group(self, cr, uid, wizard_data, context=None):
214 group_obj = self.pool.get('res.groups')
215 share_group_name = '%s: %s (%d-%s)' %('Shared', wizard_data.name, uid, time.time())
216 # create share group without putting admin in it
217 return group_obj.create(cr, UID_ROOT, {'name': share_group_name, 'share': True}, {'noadmin': True})
219 def _create_new_share_users(self, cr, uid, wizard_data, group_id, context=None):
220 """Create one new res.users record for each email address provided in
221 wizard_data.new_users, ignoring already existing users.
222 Populates wizard_data.result_line_ids with one new line for
223 each user (existing or not). New users will also have a value
224 for the password field, so they can receive it by email.
225 Returns the ids of the created users, and the ids of the
226 ignored, existing ones."""
229 user_obj = self.pool.get('res.users')
230 current_user = user_obj.browse(cr, UID_ROOT, uid, context=context)
231 # modify context to disable shortcuts when creating share users
232 context['noshortcut'] = True
235 if wizard_data.user_type == 'emails':
236 # get new user list from email data
237 new_users = (wizard_data.new_users or '').split('\n')
238 new_users += [wizard_data.email_1 or '', wizard_data.email_2 or '', wizard_data.email_3 or '']
239 for new_user in new_users:
241 new_user = new_user.strip()
242 if not new_user: continue
243 # Ignore the user if it already exists.
244 if not wizard_data.invite:
245 existing = user_obj.search(cr, UID_ROOT, [('login', '=', new_user)])
247 existing = user_obj.search(cr, UID_ROOT, [('email', '=', new_user)])
248 existing_ids.extend(existing)
250 new_line = { 'user_id': existing[0],
251 'newly_created': False}
252 wizard_data.write({'result_line_ids': [(0,0,new_line)]})
254 new_pass = generate_random_pass()
255 user_id = user_obj.create(cr, UID_ROOT, {
257 'password': new_pass,
260 'groups_id': [(6,0,[group_id])],
262 'company_id': current_user.company_id.id,
263 'company_ids': [(6, 0, [current_user.company_id.id])],
265 new_line = { 'user_id': user_id,
266 'password': new_pass,
267 'newly_created': True}
268 wizard_data.write({'result_line_ids': [(0,0,new_line)]})
269 created_ids.append(user_id)
271 elif wizard_data.user_type == 'embedded':
272 new_login = 'embedded-%s' % (uuid.uuid4().hex,)
273 new_pass = generate_random_pass()
274 user_id = user_obj.create(cr, UID_ROOT, {
276 'password': new_pass,
278 'groups_id': [(6,0,[group_id])],
280 'company_id': current_user.company_id.id,
281 'company_ids': [(6, 0, [current_user.company_id.id])],
283 new_line = { 'user_id': user_id,
284 'password': new_pass,
285 'newly_created': True}
286 wizard_data.write({'result_line_ids': [(0,0,new_line)]})
287 created_ids.append(user_id)
289 return created_ids, existing_ids
291 def _create_shortcut(self, cr, uid, values, context=None):
294 new_context = context.copy()
296 if key.startswith('default_'):
299 dataobj = self.pool.get('ir.model.data')
300 menu_id = dataobj._get_id(cr, uid, 'base', 'menu_administration_shortcut')
301 shortcut_menu_id = int(dataobj.read(cr, uid, menu_id, ['res_id'], new_context)['res_id'])
302 action_id = self.pool.get('ir.actions.act_window').create(cr, UID_ROOT, values, new_context)
303 menu_data = {'name': values['name'],
305 'action': 'ir.actions.act_window,'+str(action_id),
306 'parent_id': shortcut_menu_id,
307 'icon': 'STOCK_JUSTIFY_FILL'}
308 menu_obj = self.pool.get('ir.ui.menu')
309 menu_id = menu_obj.create(cr, UID_ROOT, menu_data)
310 sc_data = {'name': values['name'], 'sequence': UID_ROOT,'res_id': menu_id }
311 self.pool.get('ir.ui.view_sc').create(cr, uid, sc_data, new_context)
314 user_groups = set(self.pool.get('res.users').read(cr, UID_ROOT, uid, ['groups_id'])['groups_id'])
315 key = (cr.dbname, shortcut_menu_id, tuple(user_groups))
316 menu_obj._cache[key] = True
319 def _cleanup_action_context(self, context_str, user_id):
320 """Returns a dict representing the context_str evaluated (safe_eval) as
321 a dict where items that are not useful for shared actions
322 have been removed. If the evaluation of context_str as a
323 dict fails, context_str is returned unaltered.
325 :param user_id: the integer uid to be passed as 'uid' in the
331 context = safe_eval(context_str, tools.UnquoteEvalContext(), nocopy=True)
332 result = dict(context)
334 # Remove all context keys that seem to toggle default
335 # filters based on the current user, as it makes no sense
336 # for shared users, who would not see any data by default.
337 if key and key.startswith('search_default_') and 'user_id' in key:
340 # Note: must catch all exceptions, as UnquoteEvalContext may cause many
341 # different exceptions, as it shadows builtins.
342 _logger.debug("Failed to cleanup action context as it does not parse server-side", exc_info=True)
346 def _shared_action_def(self, cr, uid, wizard_data, context=None):
347 copied_action = wizard_data.action_id
349 if wizard_data.access_mode == 'readonly':
350 view_mode = wizard_data.view_type
351 view_id = copied_action.view_id.id if copied_action.view_id.type == wizard_data.view_type else False
353 view_mode = copied_action.view_mode
354 view_id = copied_action.view_id.id
358 'name': wizard_data.name,
359 'domain': copied_action.domain,
360 'context': self._cleanup_action_context(wizard_data.action_id.context, uid),
361 'res_model': copied_action.res_model,
362 'view_mode': view_mode,
363 'view_type': copied_action.view_type,
364 'search_view_id': copied_action.search_view_id.id if wizard_data.access_mode != 'readonly' else False,
368 if copied_action.view_ids:
369 action_def['view_ids'] = [(0,0,{'sequence': x.sequence,
370 'view_mode': x.view_mode,
371 'view_id': x.view_id.id })
372 for x in copied_action.view_ids
373 if (wizard_data.access_mode != 'readonly' or x.view_mode == wizard_data.view_type)
377 def _setup_action_and_shortcut(self, cr, uid, wizard_data, user_ids, make_home, context=None):
378 """Create a shortcut to reach the shared data, as well as the corresponding action, for
379 each user in ``user_ids``, and assign it as their home action if ``make_home`` is True.
380 Meant to be overridden for special cases.
382 values = self._shared_action_def(cr, uid, wizard_data, context=None)
383 user_obj = self.pool.get('res.users')
384 for user_id in user_ids:
385 action_id = self._create_shortcut(cr, user_id, values)
387 # We do this only for new share users, as existing ones already have their initial home
388 # action. Resetting to the default menu does not work well as the menu is rather empty
389 # and does not contain the shortcuts in most cases.
390 user_obj.write(cr, UID_ROOT, [user_id], {'action_id': action_id})
392 def _get_recursive_relations(self, cr, uid, model, ttypes, relation_fields=None, suffix=None, context=None):
393 """Returns list of tuples representing recursive relationships of type ``ttypes`` starting from
394 model with ID ``model_id``.
396 :param model: browsable model to start loading relationships from
397 :param ttypes: list of relationship types to follow (e.g: ['one2many','many2many'])
398 :param relation_fields: list of previously followed relationship tuples - to avoid duplicates
400 :param suffix: optional suffix to append to the field path to reach the main object
402 if relation_fields is None:
404 local_rel_fields = []
405 models = [x[1].model for x in relation_fields]
406 model_obj = self.pool.get('ir.model')
407 model_osv = self.pool.get(model.model)
408 for colinfo in model_osv._all_columns.itervalues():
409 coldef = colinfo.column
410 coltype = coldef._type
411 relation_field = None
412 if coltype in ttypes and colinfo.column._obj not in models:
413 relation_model_id = model_obj.search(cr, UID_ROOT, [('model','=',coldef._obj)])[0]
414 relation_model_browse = model_obj.browse(cr, UID_ROOT, relation_model_id, context=context)
415 relation_osv = self.pool.get(coldef._obj)
416 #skip virtual one2many fields (related, ...) as there is no reverse relationship
417 if coltype == 'one2many' and hasattr(coldef, '_fields_id'):
418 # don't record reverse path if it's not a real m2o (that happens, but rarely)
419 dest_model_ci = relation_osv._all_columns
420 reverse_rel = coldef._fields_id
421 if reverse_rel in dest_model_ci and dest_model_ci[reverse_rel].column._type == 'many2one':
422 relation_field = ('%s.%s'%(reverse_rel, suffix)) if suffix else reverse_rel
423 local_rel_fields.append((relation_field, relation_model_browse))
424 for parent in relation_osv._inherits:
425 if parent not in models:
426 parent_model = self.pool.get(parent)
427 parent_colinfos = parent_model._all_columns
428 parent_model_browse = model_obj.browse(cr, UID_ROOT,
429 model_obj.search(cr, UID_ROOT, [('model','=',parent)]))[0]
430 if relation_field and coldef._fields_id in parent_colinfos:
431 # inverse relationship is available in the parent
432 local_rel_fields.append((relation_field, parent_model_browse))
434 # TODO: can we setup a proper rule to restrict inherited models
435 # in case the parent does not contain the reverse m2o?
436 local_rel_fields.append((None, parent_model_browse))
437 if relation_model_id != model.id and coltype in ['one2many', 'many2many']:
438 local_rel_fields += self._get_recursive_relations(cr, uid, relation_model_browse,
439 [coltype], relation_fields + local_rel_fields, suffix=relation_field, context=context)
440 return local_rel_fields
442 def _get_relationship_classes(self, cr, uid, model, context=None):
443 """Computes the *relationship classes* reachable from the given
444 model. The 4 relationship classes are:
445 - [obj0]: the given model itself (and its parents via _inherits, if any)
446 - [obj1]: obj0 and all other models recursively accessible from
447 obj0 via one2many relationships
448 - [obj2]: obj0 and all other models recursively accessible from
449 obj0 via one2many and many2many relationships
450 - [obj3]: all models recursively accessible from obj1 via many2one
453 Each class is returned as a list of pairs [(field,model_browse)], where
454 ``model`` is the browse_record of a reachable ir.model, and ``field`` is
455 the dot-notation reverse relationship path coming from that model to obj0,
456 or None if there is no reverse path.
458 :return: ([obj0], [obj1], [obj2], [obj3])
460 # obj0 class and its parents
461 obj0 = [(None, model)]
462 model_obj = self.pool.get(model.model)
463 ir_model_obj = self.pool.get('ir.model')
464 for parent in model_obj._inherits:
465 parent_model_browse = ir_model_obj.browse(cr, UID_ROOT,
466 ir_model_obj.search(cr, UID_ROOT, [('model','=',parent)]))[0]
467 obj0 += [(None, parent_model_browse)]
469 obj1 = self._get_recursive_relations(cr, uid, model, ['one2many'], relation_fields=obj0, context=context)
470 obj2 = self._get_recursive_relations(cr, uid, model, ['one2many', 'many2many'], relation_fields=obj0, context=context)
471 obj3 = self._get_recursive_relations(cr, uid, model, ['many2one'], relation_fields=obj0, context=context)
472 for dummy, model in obj1:
473 obj3 += self._get_recursive_relations(cr, uid, model, ['many2one'], relation_fields=obj0, context=context)
474 return obj0, obj1, obj2, obj3
476 def _get_access_map_for_groups_and_models(self, cr, uid, group_ids, model_ids, context=None):
477 model_access_obj = self.pool.get('ir.model.access')
478 user_right_ids = model_access_obj.search(cr, uid,
479 [('group_id', 'in', group_ids), ('model_id', 'in', model_ids)],
481 user_access_matrix = {}
483 for access_right in model_access_obj.browse(cr, uid, user_right_ids, context=context):
484 access_line = user_access_matrix.setdefault(access_right.model_id.model, set())
485 for perm in FULL_ACCESS:
486 if getattr(access_right, perm, 0):
487 access_line.add(perm)
488 return user_access_matrix
490 def _add_access_rights_for_share_group(self, cr, uid, group_id, mode, fields_relations, context=None):
491 """Adds access rights to group_id on object models referenced in ``fields_relations``,
492 intersecting with access rights of current user to avoid granting too much rights
494 model_access_obj = self.pool.get('ir.model.access')
495 user_obj = self.pool.get('res.users')
496 target_model_ids = [x[1].id for x in fields_relations]
497 perms_to_add = (mode == 'readonly') and READ_ONLY_ACCESS or READ_WRITE_ACCESS
498 current_user = user_obj.browse(cr, uid, uid, context=context)
500 current_user_access_map = self._get_access_map_for_groups_and_models(cr, uid,
501 [x.id for x in current_user.groups_id], target_model_ids, context=context)
502 group_access_map = self._get_access_map_for_groups_and_models(cr, uid,
503 [group_id], target_model_ids, context=context)
504 _logger.debug("Current user access matrix: %r", current_user_access_map)
505 _logger.debug("New group current access matrix: %r", group_access_map)
507 # Create required rights if allowed by current user rights and not
509 for dummy, model in fields_relations:
510 # mail.message is transversal: it should not received directly the access rights
511 if model.model in ['mail.message']: continue
513 'name': _('Copied access for sharing'),
514 'group_id': group_id,
515 'model_id': model.id,
517 current_user_access_line = current_user_access_map.get(model.model,set())
518 existing_group_access_line = group_access_map.get(model.model,set())
519 need_creation = False
520 for perm in perms_to_add:
521 if perm in current_user_access_line \
522 and perm not in existing_group_access_line:
523 values.update({perm:True})
524 group_access_map.setdefault(model.model, set()).add(perm)
527 model_access_obj.create(cr, UID_ROOT, values)
528 _logger.debug("Creating access right for model %s with values: %r", model.model, values)
530 def _link_or_copy_current_user_rules(self, cr, current_user, group_id, fields_relations, context=None):
531 rule_obj = self.pool.get('ir.rule')
533 for group in current_user.groups_id:
534 for dummy, model in fields_relations:
535 for rule in group.rule_groups:
536 if rule.id in rules_done:
538 rules_done.add(rule.id)
539 if rule.model_id.id == model.id:
540 if 'user.' in rule.domain_force:
541 # Above pattern means there is likely a condition
542 # specific to current user, so we must copy the rule using
543 # the evaluated version of the domain.
544 # And it's better to copy one time too much than too few
545 rule_obj.copy(cr, UID_ROOT, rule.id, default={
546 'name': '%s %s' %(rule.name, _('(Copy for sharing)')),
547 'groups': [(6,0,[group_id])],
548 'domain_force': rule.domain, # evaluated version!
550 _logger.debug("Copying rule %s (%s) on model %s with domain: %s", rule.name, rule.id, model.model, rule.domain_force)
552 # otherwise we can simply link the rule to keep it dynamic
553 rule_obj.write(cr, SUPERUSER_ID, [rule.id], {
554 'groups': [(4,group_id)]
556 _logger.debug("Linking rule %s (%s) on model %s with domain: %s", rule.name, rule.id, model.model, rule.domain_force)
558 def _check_personal_rule_or_duplicate(self, cr, group_id, rule, context=None):
559 """Verifies that the given rule only belongs to the given group_id, otherwise
560 duplicate it for the current group, and unlink the previous one.
561 The duplicated rule has the original domain copied verbatim, without
563 Returns the final rule to use (browse_record), either the original one if it
564 only belongs to this group, or the copy."""
565 if len(rule.groups) == 1:
567 # duplicate it first:
568 rule_obj = self.pool.get('ir.rule')
569 new_id = rule_obj.copy(cr, UID_ROOT, rule.id,
571 'name': '%s %s' %(rule.name, _('(Duplicated for modified sharing permissions)')),
572 'groups': [(6,0,[group_id])],
573 'domain_force': rule.domain_force, # non evaluated!
575 _logger.debug("Duplicating rule %s (%s) (domain: %s) for modified access ", rule.name, rule.id, rule.domain_force)
576 # then disconnect from group_id:
577 rule.write({'groups':[(3,group_id)]}) # disconnects, does not delete!
578 return rule_obj.browse(cr, UID_ROOT, new_id, context=context)
580 def _create_or_combine_sharing_rule(self, cr, current_user, wizard_data, group_id, model_id, domain, restrict=False, rule_name=None, context=None):
581 """Add a new ir.rule entry for model_id and domain on the target group_id.
582 If ``restrict`` is True, instead of adding a rule, the domain is
583 combined with AND operator with all existing rules in the group, to implement
584 an additional restriction (as of 6.1, multiple rules in the same group are
585 OR'ed by default, so a restriction must alter all existing rules)
587 This is necessary because the personal rules of the user that is sharing
588 are first copied to the new share group. Afterwards the filters used for
589 sharing are applied as an additional layer of rules, which are likely to
590 apply to the same model. The default rule algorithm would OR them (as of 6.1),
591 which would result in a combined set of permission that could be larger
592 than those of the user that is sharing! Hence we must forcefully AND the
594 One possibly undesirable effect can appear when sharing with a
595 pre-existing group, in which case altering pre-existing rules would not
596 be desired. This is addressed in the portal module.
598 if rule_name is None:
599 rule_name = _('Sharing filter created by user %s (%s) for group %s') % \
600 (current_user.name, current_user.login, group_id)
601 rule_obj = self.pool.get('ir.rule')
602 rule_ids = rule_obj.search(cr, UID_ROOT, [('groups', 'in', group_id), ('model_id', '=', model_id)])
604 for rule in rule_obj.browse(cr, UID_ROOT, rule_ids, context=context):
605 if rule.domain_force == domain:
606 # don't create it twice!
610 _logger.debug("Ignoring sharing rule on model %s with domain: %s the same rule exists already", model_id, domain)
613 # restricting existing rules is done by adding the clause
614 # with an AND, but we can't alter the rule if it belongs to
615 # other groups, so we duplicate if needed
616 rule = self._check_personal_rule_or_duplicate(cr, group_id, rule, context=context)
617 eval_ctx = rule_obj._eval_context_for_combinations()
618 org_domain = expression.normalize_domain(eval(rule.domain_force, eval_ctx))
619 new_clause = expression.normalize_domain(eval(domain, eval_ctx))
620 combined_domain = expression.AND([new_clause, org_domain])
621 rule.write({'domain_force': combined_domain, 'name': rule.name + _('(Modified)')})
622 _logger.debug("Combining sharing rule %s on model %s with domain: %s", rule.id, model_id, domain)
623 if not rule_ids or not restrict:
624 # Adding the new rule in the group is ok for normal cases, because rules
625 # in the same group and for the same model will be combined with OR
626 # (as of v6.1), so the desired effect is achieved.
627 rule_obj.create(cr, UID_ROOT, {
629 'model_id': model_id,
630 'domain_force': domain,
631 'groups': [(4,group_id)]
633 _logger.debug("Created sharing rule on model %s with domain: %s", model_id, domain)
635 def _create_indirect_sharing_rules(self, cr, current_user, wizard_data, group_id, fields_relations, context=None):
636 rule_name = _('Indirect sharing filter created by user %s (%s) for group %s') % \
637 (current_user.name, current_user.login, group_id)
639 domain = safe_eval(wizard_data.domain)
641 for rel_field, model in fields_relations:
642 # mail.message is transversal: it should not received directly the access rights
643 if model.model in ['mail.message']: continue
645 if not rel_field: continue
646 for element in domain:
647 if expression.is_leaf(element):
648 left, operator, right = element
649 left = '%s.%s'%(rel_field, left)
650 element = left, operator, right
651 related_domain.append(element)
652 self._create_or_combine_sharing_rule(cr, current_user, wizard_data,
653 group_id, model_id=model.id, domain=str(related_domain),
654 rule_name=rule_name, restrict=True, context=context)
656 _logger.exception('Failed to create share access')
657 raise osv.except_osv(_('Sharing access cannot be created.'),
658 _('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.'))
660 def _check_preconditions(self, cr, uid, wizard_data, context=None):
661 self._assert(wizard_data.action_id and wizard_data.access_mode,
662 _('Action and Access Mode are required to create a shared access.'),
664 self._assert(self.has_share(cr, uid, wizard_data, context=context),
665 _('You must be a member of the Share/User group to use the share wizard.'),
667 if wizard_data.user_type == 'emails':
668 self._assert((wizard_data.new_users or wizard_data.email_1 or wizard_data.email_2 or wizard_data.email_3),
669 _('Please indicate the emails of the persons to share with, one per line.'),
672 def _create_share_users_group(self, cr, uid, wizard_data, context=None):
673 """Creates the appropriate share group and share users, and populates
674 result_line_ids of wizard_data with one line for each user.
676 :return: a tuple composed of the new group id (to which the shared access should be granted),
677 the ids of the new share users that have been created and the ids of the existing share users
679 group_id = self._create_share_group(cr, uid, wizard_data, context=context)
680 # First create any missing user, based on the email addresses provided
681 new_ids, existing_ids = self._create_new_share_users(cr, uid, wizard_data, group_id, context=context)
682 # Finally, setup the new action and shortcut for the users.
684 # existing users still need to join the new group
685 self.pool.get('res.users').write(cr, UID_ROOT, existing_ids, {
686 'groups_id': [(4,group_id)],
688 # existing user don't need their home action replaced, only a new shortcut
689 self._setup_action_and_shortcut(cr, uid, wizard_data, existing_ids, make_home=False, context=context)
691 # new users need a new shortcut AND a home action
692 self._setup_action_and_shortcut(cr, uid, wizard_data, new_ids, make_home=True, context=context)
693 return group_id, new_ids, existing_ids
695 def go_step_2(self, cr, uid, ids, context=None):
696 wizard_data = self.browse(cr, uid, ids[0], context=context)
697 self._check_preconditions(cr, uid, wizard_data, context=context)
699 # Create shared group and users
700 group_id, new_ids, existing_ids = self._create_share_users_group(cr, uid, wizard_data, context=context)
702 current_user = self.pool.get('res.users').browse(cr, uid, uid, context=context)
704 model_obj = self.pool.get('ir.model')
705 model_id = model_obj.search(cr, uid, [('model','=', wizard_data.action_id.res_model)])[0]
706 model = model_obj.browse(cr, uid, model_id, context=context)
709 # We have several classes of objects that should receive different access rights:
711 # - [obj0] be the target model itself (and its parents via _inherits, if any)
712 # - [obj1] be the target model and all other models recursively accessible from
713 # obj0 via one2many relationships
714 # - [obj2] be the target model and all other models recursively accessible from
715 # obj0 via one2many and many2many relationships
716 # - [obj3] be all models recursively accessible from obj1 via many2one relationships
717 # (currently not used)
718 obj0, obj1, obj2, obj3 = self._get_relationship_classes(cr, uid, model, context=context)
719 mode = wizard_data.access_mode
721 # Add access to [obj0] and [obj1] according to chosen mode
722 self._add_access_rights_for_share_group(cr, uid, group_id, mode, obj0, context=context)
723 self._add_access_rights_for_share_group(cr, uid, group_id, mode, obj1, context=context)
725 # Add read-only access (always) to [obj2]
726 self._add_access_rights_for_share_group(cr, uid, group_id, 'readonly', obj2, context=context)
729 # A. On [obj0], [obj1], [obj2]: add all rules from all groups of
730 # the user that is sharing
731 # Warning: rules must be copied instead of linked if they contain a reference
732 # to uid or if the rule is shared with other groups (and it must be replaced correctly)
733 # B. On [obj0]: 1 rule with domain of shared action
734 # C. For each model in [obj1]: 1 rule in the form:
735 # many2one_rel.domain_of_obj0
736 # where many2one_rel is the many2one used in the definition of the
737 # one2many, and domain_of_obj0 is the sharing domain
738 # For example if [obj0] is project.project with a domain of
739 # ['id', 'in', [1,2]]
740 # then we will have project.task in [obj1] and we need to create this
741 # ir.rule on project.task:
742 # ['project_id.id', 'in', [1,2]]
745 all_relations = obj0 + obj1 + obj2
746 self._link_or_copy_current_user_rules(cr, current_user, group_id, all_relations, context=context)
748 main_domain = wizard_data.domain if wizard_data.domain != '[]' else str(DOMAIN_ALL)
749 self._create_or_combine_sharing_rule(cr, current_user, wizard_data,
750 group_id, model_id=model.id, domain=main_domain,
751 restrict=True, context=context)
753 self._create_indirect_sharing_rules(cr, current_user, wizard_data, group_id, obj1, context=context)
755 # refresh wizard_data
756 wizard_data = self.browse(cr, uid, ids[0], context=context)
758 # EMAILS AND NOTIFICATIONS
759 # A. Not invite: as before
760 # -> send emails to destination users
761 # B. Invite (OpenSocial)
762 # -> subscribe all users (existing and new) to the record
763 # -> send a notification with a summary to the current record
764 # -> send a notification to all users; users allowing to receive
765 # emails in preferences will receive it
766 # new users by default receive all notifications by email
769 if not wizard_data.invite:
770 self.send_emails(cr, uid, wizard_data, context=context)
773 # Invite (OpenSocial): automatically subscribe users to the record
775 for cond in safe_eval(main_domain):
778 # Record id not found: issue
780 raise osv.except_osv(_('Record id not found'), _('The share engine has not been able to fetch a record_id for your invitation.'))
781 self.pool.get(model.model).message_subscribe(cr, uid, [res_id], new_ids + existing_ids, context=context)
782 # self.send_invite_email(cr, uid, wizard_data, context=context)
783 # self.send_invite_note(cr, uid, model.model, res_id, wizard_data, context=context)
786 # A. Not invite: as before
787 # B. Invite: skip summary screen, get back to the record
790 if not wizard_data.invite:
791 dummy, step2_form_view_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'share', 'share_step2_form')
793 'name': _('Shared access created!'),
796 'res_model': 'share.wizard',
799 'views': [(step2_form_view_id, 'form'), (False, 'tree'), (False, 'calendar'), (False, 'graph')],
800 'type': 'ir.actions.act_window',
808 'res_model': model.model,
811 'views': [(False, 'form'), (False, 'tree'), (False, 'calendar'), (False, 'graph')],
812 'type': 'ir.actions.act_window',
816 def send_invite_note(self, cr, uid, model_name, res_id, wizard_data, context=None):
817 subject = _('Invitation')
818 body = 'has been <b>shared</b> with'
820 for result_line in wizard_data.result_line_ids:
821 body += ' @%s' % (result_line.user_id.login)
822 if tmp_idx < len(wizard_data.result_line_ids)-2:
824 elif tmp_idx == len(wizard_data.result_line_ids)-2:
827 return self.pool.get(model_name).message_post(cr, uid, [res_id], body=body, context=context)
829 def send_invite_email(self, cr, uid, wizard_data, context=None):
830 # TDE Note: not updated because will disappear
831 message_obj = self.pool.get('mail.message')
832 notification_obj = self.pool.get('mail.notification')
833 user = self.pool.get('res.users').browse(cr, UID_ROOT, uid)
835 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.'))
837 # TODO: also send an HTML version of this mail
838 for result_line in wizard_data.result_line_ids:
839 email_to = result_line.user_id.email
842 subject = _('Invitation to collaborate about %s') % (wizard_data.record_name)
843 body = _("Hello,\n\n")
844 body += _("I have shared %s (%s) with you!\n\n") % (wizard_data.record_name, wizard_data.name)
845 if wizard_data.message:
846 body += "%s\n\n" % (wizard_data.message)
847 if result_line.newly_created:
848 body += _("The documents are not attached, you can view them online directly on my OpenERP server at:\n %s\n\n") % (result_line.share_url)
849 body += _("These are your credentials to access this protected area:\n")
850 body += "%s: %s" % (_("Username"), result_line.user_id.login) + "\n"
851 body += "%s: %s" % (_("Password"), result_line.password) + "\n"
852 body += "%s: %s" % (_("Database"), cr.dbname) + "\n"
853 body += _("The documents have been automatically added to your subscriptions.\n\n")
854 body += '%s\n\n' % ((user.signature or ''))
856 body += _("OpenERP is a powerful and user-friendly suite of Business Applications (CRM, Sales, HR, etc.)\n"
857 "It is open source and can be found on http://www.openerp.com.")
858 msg_id = message_obj.schedule_with_attach(cr, uid, user.email, [email_to], subject, body, model='', context=context)
859 notification_obj.create(cr, uid, {'user_id': result_line.user_id.id, 'message_id': msg_id}, context=context)
861 def send_emails(self, cr, uid, wizard_data, context=None):
862 _logger.info('Sending share notifications by email...')
863 mail_mail = self.pool.get('mail.mail')
864 user = self.pool.get('res.users').browse(cr, UID_ROOT, uid)
866 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.'))
868 # TODO: also send an HTML version of this mail
870 for result_line in wizard_data.result_line_ids:
871 email_to = result_line.user_id.email
874 subject = wizard_data.name
875 body = _("Hello,\n\n")
876 body += _("I've shared %s with you!\n\n") % wizard_data.name
877 body += _("The documents are not attached, you can view them online directly on my OpenERP server at:\n %s\n\n") % (result_line.share_url)
878 if wizard_data.message:
879 body += '%s\n\n' % (wizard_data.message)
880 if result_line.newly_created:
881 body += _("These are your credentials to access this protected area:\n")
882 body += "%s: %s\n" % (_("Username"), result_line.user_id.login)
883 body += "%s: %s\n" % (_("Password"), result_line.password)
884 body += "%s: %s\n" % (_("Database"), cr.dbname)
886 body += _("The documents have been automatically added to your current OpenERP documents.\n")
887 body += _("You may use your current login (%s) and password to view them.\n") % result_line.user_id.login
888 body += "\n\n%s\n\n" % ( (user.signature or '') )
890 body += _("OpenERP is a powerful and user-friendly suite of Business Applications (CRM, Sales, HR, etc.)\n"
891 "It is open source and can be found on http://www.openerp.com.")
892 mail_ids.append(mail_mail.create(cr, uid, {
893 'email_from': user.email,
894 'email_to': email_to,
896 'body_html': '<pre>%s</pre>' % body}, context=context))
897 # force direct delivery, as users expect instant notification
898 mail_mail.send(cr, uid, mail_ids, context=context)
899 _logger.info('%d share notification(s) sent.', len(mail_ids))
901 def onchange_embed_options(self, cr, uid, ids, opt_title, opt_search, context=None):
902 wizard = self.browse(cr, uid, ids[0], context)
903 options = dict(title=opt_title, search=opt_search)
904 return {'value': {'embed_code': self._generate_embedded_code(wizard, options)}}
908 class share_result_line(osv.osv_memory):
909 _name = 'share.wizard.result.line'
910 _rec_name = 'user_id'
913 def _share_url(self, cr, uid, ids, _fieldname, _args, context=None):
914 result = dict.fromkeys(ids, '')
915 for this in self.browse(cr, uid, ids, context=context):
916 data = dict(dbname=cr.dbname, login=this.login, password=this.password)
917 if this.share_wizard_id and this.share_wizard_id.action_id:
918 data['action_id'] = this.share_wizard_id.action_id.id
919 ctx = dict(context, share_url_template_hash_arguments=['action_id'])
920 result[this.id] = this.share_wizard_id.share_url_template(context=ctx) % data
924 'user_id': fields.many2one('res.users', required=True, readonly=True),
925 'login': fields.related('user_id', 'login', string='Login', type='char', size=64, required=True, readonly=True),
926 'password': fields.char('Password', size=64, readonly=True),
927 'share_url': fields.function(_share_url, string='Share URL', type='char', size=512),
928 'share_wizard_id': fields.many2one('share.wizard', 'Share Wizard', required=True, ondelete='cascade'),
929 'newly_created': fields.boolean('Newly created', readonly=True),
932 'newly_created': True,
935 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: