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