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