[MERGE] trunk
[odoo/odoo.git] / openerp / addons / base / res / res_users.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2009 Tiny SPRL (<http://tiny.be>).
6 #    Copyright (C) 2010-2011 OpenERP s.a. (<http://openerp.com>).
7 #
8 #    This program is free software: you can redistribute it and/or modify
9 #    it under the terms of the GNU Affero General Public License as
10 #    published by the Free Software Foundation, either version 3 of the
11 #    License, or (at your option) any later version.
12 #
13 #    This program is distributed in the hope that it will be useful,
14 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
15 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 #    GNU Affero General Public License for more details.
17 #
18 #    You should have received a copy of the GNU Affero General Public License
19 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
20 #
21 ##############################################################################
22
23 import logging
24 from functools import partial
25
26 import pytz
27
28 import netsvc
29 import pooler
30 import tools
31 from osv import fields,osv
32 from osv.orm import browse_record
33 from service import security
34 from tools.translate import _
35 import openerp
36 import openerp.exceptions
37
38 _logger = logging.getLogger(__name__)
39
40 class groups(osv.osv):
41     _name = "res.groups"
42     _description = "Access Groups"
43     _rec_name = 'full_name'
44
45     def _get_full_name(self, cr, uid, ids, field, arg, context=None):
46         res = {}
47         for g in self.browse(cr, uid, ids, context):
48             if g.category_id:
49                 res[g.id] = '%s / %s' % (g.category_id.name, g.name)
50             else:
51                 res[g.id] = g.name
52         return res
53
54     _columns = {
55         'name': fields.char('Name', size=64, required=True, translate=True),
56         'users': fields.many2many('res.users', 'res_groups_users_rel', 'gid', 'uid', 'Users'),
57         'model_access': fields.one2many('ir.model.access', 'group_id', 'Access Controls'),
58         'rule_groups': fields.many2many('ir.rule', 'rule_group_rel',
59             'group_id', 'rule_group_id', 'Rules', domain=[('global', '=', False)]),
60         'menu_access': fields.many2many('ir.ui.menu', 'ir_ui_menu_group_rel', 'gid', 'menu_id', 'Access Menu'),
61         'comment' : fields.text('Comment', size=250, translate=True),
62         'category_id': fields.many2one('ir.module.category', 'Application', select=True),
63         'full_name': fields.function(_get_full_name, type='char', string='Group Name'),
64     }
65
66     _sql_constraints = [
67         ('name_uniq', 'unique (category_id, name)', 'The name of the group must be unique !')
68     ]
69
70     def search(self, cr, uid, args, offset=0, limit=None, order=None, context=None, count=False):
71         # add explicit ordering if search is sorted on full_name
72         if order and order.startswith('full_name'):
73             ids = super(groups, self).search(cr, uid, args, context=context)
74             gs = self.browse(cr, uid, ids, context)
75             gs.sort(key=lambda g: g.full_name, reverse=order.endswith('DESC'))
76             gs = gs[offset:offset+limit] if limit else gs[offset:]
77             return map(int, gs)
78         return super(groups, self).search(cr, uid, args, offset, limit, order, context, count)
79
80     def copy(self, cr, uid, id, default=None, context=None):
81         group_name = self.read(cr, uid, [id], ['name'])[0]['name']
82         default.update({'name': _('%s (copy)')%group_name})
83         return super(groups, self).copy(cr, uid, id, default, context)
84
85     def write(self, cr, uid, ids, vals, context=None):
86         if 'name' in vals:
87             if vals['name'].startswith('-'):
88                 raise osv.except_osv(_('Error'),
89                         _('The name of the group can not start with "-"'))
90         res = super(groups, self).write(cr, uid, ids, vals, context=context)
91         self.pool.get('ir.model.access').call_cache_clearing_methods(cr)
92         return res
93
94     def create(self, cr, uid, vals, context=None):
95         if 'name' in vals:
96             if vals['name'].startswith('-'):
97                 raise osv.except_osv(_('Error'),
98                         _('The name of the group can not start with "-"'))
99         gid = super(groups, self).create(cr, uid, vals, context=context)
100         if context and context.get('noadmin', False):
101             pass
102         else:
103             # assign this new group to user_root
104             user_obj = self.pool.get('res.users')
105             aid = user_obj.browse(cr, 1, user_obj._get_admin_id(cr))
106             if aid:
107                 aid.write({'groups_id': [(4, gid)]})
108         return gid
109
110     def unlink(self, cr, uid, ids, context=None):
111         group_users = []
112         for record in self.read(cr, uid, ids, ['users'], context=context):
113             if record['users']:
114                 group_users.extend(record['users'])
115         if group_users:
116             user_names = [user.name for user in self.pool.get('res.users').browse(cr, uid, group_users, context=context)]
117             user_names = list(set(user_names))
118             if len(user_names) >= 5:
119                 user_names = user_names[:5] + ['...']
120             raise osv.except_osv(_('Warning !'),
121                         _('Group(s) cannot be deleted, because some user(s) still belong to them: %s !') % \
122                             ', '.join(user_names))
123         return super(groups, self).unlink(cr, uid, ids, context=context)
124
125     def get_extended_interface_group(self, cr, uid, context=None):
126         data_obj = self.pool.get('ir.model.data')
127         extended_group_data_id = data_obj._get_id(cr, uid, 'base', 'group_extended')
128         return data_obj.browse(cr, uid, extended_group_data_id, context=context).res_id
129
130 groups()
131
132 def _lang_get(self, cr, uid, context=None):
133     obj = self.pool.get('res.lang')
134     ids = obj.search(cr, uid, [('translatable','=',True)])
135     res = obj.read(cr, uid, ids, ['code', 'name'], context=context)
136     res = [(r['code'], r['name']) for r in res]
137     return res
138
139 def _tz_get(self,cr,uid, context=None):
140     return [(x, x) for x in pytz.all_timezones]
141
142 class users(osv.osv):
143     __admin_ids = {}
144     _uid_cache = {}
145     _name = "res.users"
146     _order = 'name'
147
148     WELCOME_MAIL_SUBJECT = u"Welcome to OpenERP"
149     WELCOME_MAIL_BODY = u"An OpenERP account has been created for you, "\
150         "\"%(name)s\".\n\nYour login is %(login)s, "\
151         "you should ask your supervisor or system administrator if you "\
152         "haven't been given your password yet.\n\n"\
153         "If you aren't %(name)s, this email reached you errorneously, "\
154         "please delete it."
155
156     def get_welcome_mail_subject(self, cr, uid, context=None):
157         """ Returns the subject of the mail new users receive (when
158         created via the res.config.users wizard), default implementation
159         is to return config_users.WELCOME_MAIL_SUBJECT
160         """
161         return self.WELCOME_MAIL_SUBJECT
162     def get_welcome_mail_body(self, cr, uid, context=None):
163         """ Returns the subject of the mail new users receive (when
164         created via the res.config.users wizard), default implementation
165         is to return config_users.WELCOME_MAIL_BODY
166         """
167         return self.WELCOME_MAIL_BODY
168
169     def get_current_company(self, cr, uid):
170         cr.execute('select company_id, res_company.name from res_users left join res_company on res_company.id = company_id where res_users.id=%s' %uid)
171         return cr.fetchall()
172
173     def send_welcome_email(self, cr, uid, id, context=None):
174         if isinstance(id,list): id = id[0]
175         user = self.read(cr, uid, id, ['email','login','name', 'user_email'], context=context)
176         email = user['email'] or user['user_email']
177
178         ir_mail_server = self.pool.get('ir.mail_server')
179         msg = ir_mail_server.build_email(email_from=None, # take config default
180                                          email_to=[email],
181                                          subject=self.get_welcome_mail_subject(cr, uid, context=context),
182                                          body=(self.get_welcome_mail_body(cr, uid, context=context) % user))
183         return ir_mail_server.send_email(cr, uid, msg, context=context)
184
185     def _set_interface_type(self, cr, uid, ids, name, value, arg, context=None):
186         """Implementation of 'view' function field setter, sets the type of interface of the users.
187         @param name: Name of the field
188         @param arg: User defined argument
189         @param value: new value returned
190         @return:  True/False
191         """
192         if not value or value not in ['simple','extended']:
193             return False
194         group_obj = self.pool.get('res.groups')
195         extended_group_id = group_obj.get_extended_interface_group(cr, uid, context=context)
196         # First always remove the users from the group (avoids duplication if called twice)
197         self.write(cr, uid, ids, {'groups_id': [(3, extended_group_id)]}, context=context)
198         # Then add them back if requested
199         if value == 'extended':
200             self.write(cr, uid, ids, {'groups_id': [(4, extended_group_id)]}, context=context)
201         return True
202
203
204     def _get_interface_type(self, cr, uid, ids, name, args, context=None):
205         """Implementation of 'view' function field getter, returns the type of interface of the users.
206         @param field_name: Name of the field
207         @param arg: User defined argument
208         @return:  Dictionary of values
209         """
210         group_obj = self.pool.get('res.groups')
211         extended_group_id = group_obj.get_extended_interface_group(cr, uid, context=context)
212         extended_users = group_obj.read(cr, uid, extended_group_id, ['users'], context=context)['users']
213         return dict(zip(ids, ['extended' if user in extended_users else 'simple' for user in ids]))
214
215     def _set_new_password(self, cr, uid, id, name, value, args, context=None):
216         if value is False:
217             # Do not update the password if no value is provided, ignore silently.
218             # For example web client submits False values for all empty fields.
219             return
220         if uid == id:
221             # To change their own password users must use the client-specific change password wizard,
222             # so that the new password is immediately used for further RPC requests, otherwise the user
223             # will face unexpected 'Access Denied' exceptions.
224             raise osv.except_osv(_('Operation Canceled'), _('Please use the change password wizard (in User Preferences or User menu) to change your own password.'))
225         self.write(cr, uid, id, {'password': value})
226
227     def _get_password(self, cr, uid, ids, arg, karg, context=None):
228         return dict.fromkeys(ids, '')
229
230     _columns = {
231         'id': fields.integer('ID'),
232         'name': fields.char('User Name', size=64, required=True, select=True,
233                             help="The new user's real name, used for searching"
234                                  " and most listings"),
235         'login': fields.char('Login', size=64, required=True,
236                              help="Used to log into the system"),
237         'password': fields.char('Password', size=64, invisible=True, help="Keep empty if you don't want the user to be able to connect on the system."),
238         'new_password': fields.function(_get_password, type='char', size=64,
239                                 fnct_inv=_set_new_password,
240                                 string='Set password', help="Specify a value only when creating a user or if you're changing the user's password, "
241                                                             "otherwise leave empty. After a change of password, the user has to login again."),
242         'user_email': fields.char('Email', size=64),
243         'signature': fields.text('Signature', size=64),
244         'active': fields.boolean('Active'),
245         'action_id': fields.many2one('ir.actions.actions', 'Home Action', help="If specified, this action will be opened at logon for this user, in addition to the standard menu."),
246         'menu_id': fields.many2one('ir.actions.actions', 'Menu Action', help="If specified, the action will replace the standard menu for this user."),
247         'groups_id': fields.many2many('res.groups', 'res_groups_users_rel', 'uid', 'gid', 'Groups'),
248
249         # Special behavior for this field: res.company.search() will only return the companies
250         # available to the current user (should be the user's companies?), when the user_preference
251         # context is set.
252         'company_id': fields.many2one('res.company', 'Company', required=True,
253             help="The company this user is currently working for.", context={'user_preference': True}),
254
255         'company_ids':fields.many2many('res.company','res_company_users_rel','user_id','cid','Companies'),
256         'context_lang': fields.selection(_lang_get, 'Language', required=True,
257             help="The default language used in the graphical user interface, when translations are available. To add a new language, you can use the 'Load an Official Translation' wizard available from the 'Administration' menu."),
258         'context_tz': fields.selection(_tz_get,  'Timezone', size=64,
259             help="The user's timezone, used to output proper date and time values inside printed reports. "
260                  "It is important to set a value for this field. You should use the same timezone "
261                  "that is otherwise used to pick and render date and time values: your computer's timezone."),
262         'view': fields.function(_get_interface_type, type='selection', fnct_inv=_set_interface_type,
263                                 selection=[('simple','Simplified'),('extended','Extended')],
264                                 string='Interface', help="OpenERP offers a simplified and an extended user interface. If you use OpenERP for the first time we strongly advise you to select the simplified interface, which has less features but is easier to use. You can switch to the other interface from the User/Preferences menu at any time."),
265         'menu_tips': fields.boolean('Menu Tips', help="Check out this box if you want to always display tips on each menu action"),
266         'date': fields.datetime('Latest Connection', readonly=True),
267     }
268
269     def on_change_company_id(self, cr, uid, ids, company_id):
270         return {
271                 'warning' : {
272                     'title': _("Company Switch Warning"),
273                     'message': _("Please keep in mind that documents currently displayed may not be relevant after switching to another company. If you have unsaved changes, please make sure to save and close all forms before switching to a different company. (You can click on Cancel in the User Preferences now)"),
274                 }
275         }
276
277     def read(self,cr, uid, ids, fields=None, context=None, load='_classic_read'):
278         def override_password(o):
279             if 'password' in o and ( 'id' not in o or o['id'] != uid ):
280                 o['password'] = '********'
281             return o
282         result = super(users, self).read(cr, uid, ids, fields, context, load)
283         canwrite = self.pool.get('ir.model.access').check(cr, uid, 'res.users', 'write', False)
284         if not canwrite:
285             if isinstance(ids, (int, float)):
286                 result = override_password(result)
287             else:
288                 result = map(override_password, result)
289         return result
290
291
292     def _check_company(self, cr, uid, ids, context=None):
293         return all(((this.company_id in this.company_ids) or not this.company_ids) for this in self.browse(cr, uid, ids, context))
294
295     _constraints = [
296         (_check_company, 'The chosen company is not in the allowed companies for this user', ['company_id', 'company_ids']),
297     ]
298
299     _sql_constraints = [
300         ('login_key', 'UNIQUE (login)',  'You can not have two users with the same login !')
301     ]
302
303     def _get_email_from(self, cr, uid, ids, context=None):
304         if not isinstance(ids, list):
305             ids = [ids]
306         res = dict.fromkeys(ids, False)
307         for user in self.browse(cr, uid, ids, context=context):
308             if user.user_email:
309                 res[user.id] = "%s <%s>" % (user.name, user.user_email)
310         return res
311
312     def _get_admin_id(self, cr):
313         if self.__admin_ids.get(cr.dbname) is None:
314             ir_model_data_obj = self.pool.get('ir.model.data')
315             mdid = ir_model_data_obj._get_id(cr, 1, 'base', 'user_root')
316             self.__admin_ids[cr.dbname] = ir_model_data_obj.read(cr, 1, [mdid], ['res_id'])[0]['res_id']
317         return self.__admin_ids[cr.dbname]
318
319     def _get_company(self,cr, uid, context=None, uid2=False):
320         if not uid2:
321             uid2 = uid
322         user = self.pool.get('res.users').read(cr, uid, uid2, ['company_id'], context)
323         company_id = user.get('company_id', False)
324         return company_id and company_id[0] or False
325
326     def _get_companies(self, cr, uid, context=None):
327         c = self._get_company(cr, uid, context)
328         if c:
329             return [c]
330         return False
331
332     def _get_menu(self,cr, uid, context=None):
333         dataobj = self.pool.get('ir.model.data')
334         try:
335             model, res_id = dataobj.get_object_reference(cr, uid, 'base', 'action_menu_admin')
336             if model != 'ir.actions.act_window':
337                 return False
338             return res_id
339         except ValueError:
340             return False
341
342     def _get_group(self,cr, uid, context=None):
343         dataobj = self.pool.get('ir.model.data')
344         result = []
345         try:
346             dummy,group_id = dataobj.get_object_reference(cr, 1, 'base', 'group_user')
347             result.append(group_id)
348             dummy,group_id = dataobj.get_object_reference(cr, 1, 'base', 'group_partner_manager')
349             result.append(group_id)
350         except ValueError:
351             # If these groups does not exists anymore
352             pass
353         return result
354
355     _defaults = {
356         'password' : '',
357         'context_lang': 'en_US',
358         'active' : True,
359         'menu_id': _get_menu,
360         'company_id': _get_company,
361         'company_ids': _get_companies,
362         'groups_id': _get_group,
363         'menu_tips': False
364     }
365
366     # User can write to a few of her own fields (but not her groups for example)
367     SELF_WRITEABLE_FIELDS = ['menu_tips','view', 'password', 'signature', 'action_id', 'company_id', 'user_email', 'name']
368
369     def write(self, cr, uid, ids, values, context=None):
370         if not hasattr(ids, '__iter__'):
371             ids = [ids]
372         if ids == [uid]:
373             for key in values.keys():
374                 if not (key in self.SELF_WRITEABLE_FIELDS or key.startswith('context_')):
375                     break
376             else:
377                 if 'company_id' in values:
378                     if not (values['company_id'] in self.read(cr, 1, uid, ['company_ids'], context=context)['company_ids']):
379                         del values['company_id']
380                 uid = 1 # safe fields only, so we write as super-user to bypass access rights
381
382         res = super(users, self).write(cr, uid, ids, values, context=context)
383
384         # clear caches linked to the users
385         self.pool.get('ir.model.access').call_cache_clearing_methods(cr)
386         clear = partial(self.pool.get('ir.rule').clear_cache, cr)
387         map(clear, ids)
388         db = cr.dbname
389         if db in self._uid_cache:
390             for id in ids:
391                 if id in self._uid_cache[db]:
392                     del self._uid_cache[db][id]
393
394         return res
395
396     def unlink(self, cr, uid, ids, context=None):
397         if 1 in ids:
398             raise osv.except_osv(_('Can not remove root user!'), _('You can not remove the admin user as it is used internally for resources created by OpenERP (updates, module installation, ...)'))
399         db = cr.dbname
400         if db in self._uid_cache:
401             for id in ids:
402                 if id in self._uid_cache[db]:
403                     del self._uid_cache[db][id]
404         return super(users, self).unlink(cr, uid, ids, context=context)
405
406     def name_search(self, cr, user, name='', args=None, operator='ilike', context=None, limit=100):
407         if not args:
408             args=[]
409         if not context:
410             context={}
411         ids = []
412         if name:
413             ids = self.search(cr, user, [('login','=',name)]+ args, limit=limit)
414         if not ids:
415             ids = self.search(cr, user, [('name',operator,name)]+ args, limit=limit)
416         return self.name_get(cr, user, ids)
417
418     def copy(self, cr, uid, id, default=None, context=None):
419         user2copy = self.read(cr, uid, [id], ['login','name'])[0]
420         if default is None:
421             default = {}
422         copy_pattern = _("%s (copy)")
423         copydef = dict(login=(copy_pattern % user2copy['login']),
424                        name=(copy_pattern % user2copy['name']),
425                        )
426         copydef.update(default)
427         return super(users, self).copy(cr, uid, id, copydef, context)
428
429     def context_get(self, cr, uid, context=None):
430         user = self.browse(cr, uid, uid, context)
431         result = {}
432         for k in self._columns.keys():
433             if k.startswith('context_'):
434                 res = getattr(user,k) or False
435                 if isinstance(res, browse_record):
436                     res = res.id
437                 result[k[8:]] = res or False
438         return result
439
440     def action_get(self, cr, uid, context=None):
441         dataobj = self.pool.get('ir.model.data')
442         data_id = dataobj._get_id(cr, 1, 'base', 'action_res_users_my')
443         return dataobj.browse(cr, uid, data_id, context=context).res_id
444
445     def authenticate(self, db, login, password, user_agent_env):
446         """Verifies and returns the user ID corresponding to the given
447           ``login`` and ``password`` combination, or False if there was
448           no matching user.
449
450            :param str db: the database on which user is trying to authenticate
451            :param str login: username
452            :param str password: user password
453            :param dict user_agent_env: environment dictionary describing any
454                relevant environment attributes
455         """
456         uid = self.login(db, login, password)
457         if uid == openerp.SUPERUSER_ID:
458             # Successfully logged in as admin!
459             # Attempt to guess the web base url...
460             if user_agent_env and user_agent_env.get('base_location'):
461                 cr = pooler.get_db(db).cursor()
462                 try:
463                     self.pool.get('ir.config_parameter').set_param(cr, uid, 'web.base.url',
464                                                                    user_agent_env['base_location'])
465                     cr.commit()
466                 except Exception:
467                     _logger.exception("Failed to update web.base.url configuration parameter")
468                 finally:
469                     cr.close()
470         return uid
471
472     def login(self, db, login, password):
473         if not password:
474             return False
475         cr = pooler.get_db(db).cursor()
476         try:
477             # We autocommit: our single request will be performed atomically.
478             # (In this way, there is no opportunity to have two transactions
479             # interleaving ther cr.execute()..cr.commit() calls and have one
480             # of them rollbacked due to a concurrent access.)
481             # We effectively unconditionally write the res_users line.
482             cr.autocommit(True)
483             cr.execute("""UPDATE res_users
484                             SET date = now() AT TIME ZONE 'UTC'
485                             WHERE login=%s AND password=%s AND active RETURNING id""",
486                        (tools.ustr(login), tools.ustr(password)))
487             res = cr.fetchone()
488             if res:
489                 return res[0]
490             else:
491                 return False
492         finally:
493             cr.close()
494
495     def check_super(self, passwd):
496         if passwd == tools.config['admin_passwd']:
497             return True
498         else:
499             raise openerp.exceptions.AccessDenied()
500
501     def check(self, db, uid, passwd):
502         """Verifies that the given (uid, password) pair is authorized for the database ``db`` and
503            raise an exception if it is not."""
504         if not passwd:
505             # empty passwords disallowed for obvious security reasons
506             raise openerp.exceptions.AccessDenied()
507         if self._uid_cache.get(db, {}).get(uid) == passwd:
508             return
509         cr = pooler.get_db(db).cursor()
510         try:
511             cr.execute('SELECT COUNT(1) FROM res_users WHERE id=%s AND password=%s AND active=%s',
512                         (int(uid), passwd, True))
513             res = cr.fetchone()[0]
514             if not res:
515                 raise openerp.exceptions.AccessDenied()
516             if self._uid_cache.has_key(db):
517                 ulist = self._uid_cache[db]
518                 ulist[uid] = passwd
519             else:
520                 self._uid_cache[db] = {uid:passwd}
521         finally:
522             cr.close()
523
524     def access(self, db, uid, passwd, sec_level, ids):
525         if not passwd:
526             return False
527         cr = pooler.get_db(db).cursor()
528         try:
529             cr.execute('SELECT id FROM res_users WHERE id=%s AND password=%s', (uid, passwd))
530             res = cr.fetchone()
531             if not res:
532                 raise openerp.exceptions.AccessDenied()
533             return res[0]
534         finally:
535             cr.close()
536
537     def change_password(self, cr, uid, old_passwd, new_passwd, context=None):
538         """Change current user password. Old password must be provided explicitly
539         to prevent hijacking an existing user session, or for cases where the cleartext
540         password is not used to authenticate requests.
541
542         :return: True
543         :raise: openerp.exceptions.AccessDenied when old password is wrong
544         :raise: except_osv when new password is not set or empty
545         """
546         self.check(cr.dbname, uid, old_passwd)
547         if new_passwd:
548             return self.write(cr, uid, uid, {'password': new_passwd})
549         raise osv.except_osv(_('Warning!'), _("Setting empty passwords is not allowed for security reasons!"))
550
551 users()
552
553
554
555 #
556 # Extension of res.groups and res.users with a relation for "implied" or 
557 # "inherited" groups.  Once a user belongs to a group, it automatically belongs
558 # to the implied groups (transitively).
559 #
560
561 class cset(object):
562     """ A cset (constrained set) is a set of elements that may be constrained to
563         be a subset of other csets.  Elements added to a cset are automatically
564         added to its supersets.  Cycles in the subset constraints are supported.
565     """
566     def __init__(self, xs):
567         self.supersets = set()
568         self.elements = set(xs)
569     def subsetof(self, other):
570         if other is not self:
571             self.supersets.add(other)
572             other.update(self.elements)
573     def update(self, xs):
574         xs = set(xs) - self.elements
575         if xs:      # xs will eventually be empty in case of a cycle
576             self.elements.update(xs)
577             for s in self.supersets:
578                 s.update(xs)
579     def __iter__(self):
580         return iter(self.elements)
581
582 def concat(ls):
583     """ return the concatenation of a list of iterables """
584     res = []
585     for l in ls: res.extend(l)
586     return res
587
588
589
590 class groups_implied(osv.osv):
591     _inherit = 'res.groups'
592
593     def _get_trans_implied(self, cr, uid, ids, field, arg, context=None):
594         "computes the transitive closure of relation implied_ids"
595         memo = {}           # use a memo for performance and cycle avoidance
596         def computed_set(g):
597             if g not in memo:
598                 memo[g] = cset(g.implied_ids)
599                 for h in g.implied_ids:
600                     computed_set(h).subsetof(memo[g])
601             return memo[g]
602
603         res = {}
604         for g in self.browse(cr, 1, ids, context):
605             res[g.id] = map(int, computed_set(g))
606         return res
607
608     _columns = {
609         'implied_ids': fields.many2many('res.groups', 'res_groups_implied_rel', 'gid', 'hid',
610             string='Inherits', help='Users of this group automatically inherit those groups'),
611         'trans_implied_ids': fields.function(_get_trans_implied,
612             type='many2many', relation='res.groups', string='Transitively inherits'),
613     }
614
615     def create(self, cr, uid, values, context=None):
616         users = values.pop('users', None)
617         gid = super(groups_implied, self).create(cr, uid, values, context)
618         if users:
619             # delegate addition of users to add implied groups
620             self.write(cr, uid, [gid], {'users': users}, context)
621         return gid
622
623     def write(self, cr, uid, ids, values, context=None):
624         res = super(groups_implied, self).write(cr, uid, ids, values, context)
625         if values.get('users') or values.get('implied_ids'):
626             # add all implied groups (to all users of each group)
627             for g in self.browse(cr, uid, ids):
628                 gids = map(int, g.trans_implied_ids)
629                 vals = {'users': [(4, u.id) for u in g.users]}
630                 super(groups_implied, self).write(cr, uid, gids, vals, context)
631         return res
632
633 groups_implied()
634
635 class users_implied(osv.osv):
636     _inherit = 'res.users'
637
638     def create(self, cr, uid, values, context=None):
639         groups = values.pop('groups_id', None)
640         user_id = super(users_implied, self).create(cr, uid, values, context)
641         if groups:
642             # delegate addition of groups to add implied groups
643             self.write(cr, uid, [user_id], {'groups_id': groups}, context)
644         return user_id
645
646     def write(self, cr, uid, ids, values, context=None):
647         if not isinstance(ids,list):
648             ids = [ids]
649         res = super(users_implied, self).write(cr, uid, ids, values, context)
650         if values.get('groups_id'):
651             # add implied groups for all users
652             for user in self.browse(cr, uid, ids):
653                 gs = set(concat([g.trans_implied_ids for g in user.groups_id]))
654                 vals = {'groups_id': [(4, g.id) for g in gs]}
655                 super(users_implied, self).write(cr, uid, [user.id], vals, context)
656         return res
657
658 users_implied()
659
660
661
662 #
663 # Extension of res.groups and res.users for the special groups view in the users
664 # form.  This extension presents groups with selection and boolean widgets:
665 # - Groups are shown by application, with boolean and/or selection fields.
666 #   Selection fields typically defines a role "Name" for the given application.
667 # - Uncategorized groups are presented as boolean fields and grouped in a
668 #   section "Others".
669 #
670 # The user form view is modified by an inherited view (base.user_groups_view);
671 # the inherited view replaces the field 'groups_id' by a set of reified group
672 # fields (boolean or selection fields).  The arch of that view is regenerated
673 # each time groups are changed.
674 #
675 # Naming conventions for reified groups fields:
676 # - boolean field 'in_group_ID' is True iff
677 #       ID is in 'groups_id'
678 # - boolean field 'in_groups_ID1_..._IDk' is True iff
679 #       any of ID1, ..., IDk is in 'groups_id'
680 # - selection field 'sel_groups_ID1_..._IDk' is ID iff
681 #       ID is in 'groups_id' and ID is maximal in the set {ID1, ..., IDk}
682
683 def name_boolean_group(id): return 'in_group_' + str(id)
684 def name_boolean_groups(ids): return 'in_groups_' + '_'.join(map(str, ids))
685 def name_selection_groups(ids): return 'sel_groups_' + '_'.join(map(str, ids))
686
687 def is_boolean_group(name): return name.startswith('in_group_')
688 def is_boolean_groups(name): return name.startswith('in_groups_')
689 def is_selection_groups(name): return name.startswith('sel_groups_')
690 def is_reified_group(name):
691     return is_boolean_group(name) or is_boolean_groups(name) or is_selection_groups(name)
692
693 def get_boolean_group(name): return int(name[9:])
694 def get_boolean_groups(name): return map(int, name[10:].split('_'))
695 def get_selection_groups(name): return map(int, name[11:].split('_'))
696
697 def partition(f, xs):
698     "return a pair equivalent to (filter(f, xs), filter(lambda x: not f(x), xs))"
699     yes, nos = [], []
700     for x in xs:
701         (yes if f(x) else nos).append(x)
702     return yes, nos
703
704
705
706 class groups_view(osv.osv):
707     _inherit = 'res.groups'
708
709     def create(self, cr, uid, values, context=None):
710         res = super(groups_view, self).create(cr, uid, values, context)
711         self.update_user_groups_view(cr, uid, context)
712         return res
713
714     def write(self, cr, uid, ids, values, context=None):
715         res = super(groups_view, self).write(cr, uid, ids, values, context)
716         self.update_user_groups_view(cr, uid, context)
717         return res
718
719     def unlink(self, cr, uid, ids, context=None):
720         res = super(groups_view, self).unlink(cr, uid, ids, context)
721         self.update_user_groups_view(cr, uid, context)
722         return res
723
724     def update_user_groups_view(self, cr, uid, context=None):
725         # the view with id 'base.user_groups_view' inherits the user form view,
726         # and introduces the reified group fields
727         view = self.get_user_groups_view(cr, uid, context)
728         if view:
729             xml = u"""<?xml version="1.0" encoding="utf-8"?>
730 <!-- GENERATED AUTOMATICALLY BY GROUPS -->
731 <field name="groups_id" position="replace">
732 %s
733 %s
734 </field>
735 """
736             xml1, xml2 = [], []
737             xml1.append('<separator string="%s" colspan="4"/>' % _('Applications'))
738             for app, kind, gs in self.get_groups_by_application(cr, uid, context):
739                 if kind == 'selection':
740                     # application name with a selection field
741                     field_name = name_selection_groups(map(int, gs))
742                     xml1.append('<field name="%s"/>' % field_name)
743                     xml1.append('<newline/>')
744                 else:
745                     # application separator with boolean fields
746                     app_name = app and app.name or _('Other')
747                     xml2.append('<separator string="%s" colspan="4"/>' % app_name)
748                     for g in gs:
749                         field_name = name_boolean_group(g.id)
750                         xml2.append('<field name="%s"/>' % field_name)
751             view.write({'arch': xml % ('\n'.join(xml1), '\n'.join(xml2))})
752         return True
753
754     def get_user_groups_view(self, cr, uid, context=None):
755         try:
756             view = self.pool.get('ir.model.data').get_object(cr, 1, 'base', 'user_groups_view', context)
757             assert view and view._table_name == 'ir.ui.view'
758         except Exception:
759             view = False
760         return view
761
762     def get_application_groups(self, cr, uid, domain=None, context=None):
763         return self.search(cr, uid, domain or [])
764
765     def get_groups_by_application(self, cr, uid, context=None):
766         """ return all groups classified by application (module category), as a list of pairs:
767                 [(app, kind, [group, ...]), ...],
768             where app and group are browse records, and kind is either 'boolean' or 'selection'.
769             Applications are given in sequence order.  If kind is 'selection', the groups are
770             given in reverse implication order.
771         """
772         def linearized(gs):
773             gs = set(gs)
774             # determine sequence order: a group should appear after its implied groups
775             order = dict.fromkeys(gs, 0)
776             for g in gs:
777                 for h in gs.intersection(g.trans_implied_ids):
778                     order[h] -= 1
779             # check whether order is total, i.e., sequence orders are distinct
780             if len(set(order.itervalues())) == len(gs):
781                 return sorted(gs, key=lambda g: order[g])
782             return None
783
784         # classify all groups by application
785         gids = self.get_application_groups(cr, uid, context=context)
786         by_app, others = {}, []
787         for g in self.browse(cr, uid, gids, context):
788             if g.category_id:
789                 by_app.setdefault(g.category_id, []).append(g)
790             else:
791                 others.append(g)
792         # build the result
793         res = []
794         apps = sorted(by_app.iterkeys(), key=lambda a: a.sequence or 0)
795         for app in apps:
796             gs = linearized(by_app[app])
797             if gs:
798                 res.append((app, 'selection', gs))
799             else:
800                 res.append((app, 'boolean', by_app[app]))
801         if others:
802             res.append((False, 'boolean', others))
803         return res
804
805 groups_view()
806
807 class users_view(osv.osv):
808     _inherit = 'res.users'
809
810     def create(self, cr, uid, values, context=None):
811         self._set_reified_groups(values)
812         return super(users_view, self).create(cr, uid, values, context)
813
814     def write(self, cr, uid, ids, values, context=None):
815         self._set_reified_groups(values)
816         return super(users_view, self).write(cr, uid, ids, values, context)
817
818     def _set_reified_groups(self, values):
819         """ reflect reified group fields in values['groups_id'] """
820         if 'groups_id' in values:
821             # groups are already given, ignore group fields
822             for f in filter(is_reified_group, values.iterkeys()):
823                 del values[f]
824             return
825
826         add, remove = [], []
827         for f in values.keys():
828             if is_boolean_group(f):
829                 target = add if values.pop(f) else remove
830                 target.append(get_boolean_group(f))
831             elif is_boolean_groups(f):
832                 if not values.pop(f):
833                     remove.extend(get_boolean_groups(f))
834             elif is_selection_groups(f):
835                 remove.extend(get_selection_groups(f))
836                 selected = values.pop(f)
837                 if selected:
838                     add.append(selected)
839         # update values *only* if groups are being modified, otherwise
840         # we introduce spurious changes that might break the super.write() call.
841         if add or remove:
842             # remove groups in 'remove' and add groups in 'add'
843             values['groups_id'] = [(3, id) for id in remove] + [(4, id) for id in add]
844
845     def default_get(self, cr, uid, fields, context=None):
846         group_fields, fields = partition(is_reified_group, fields)
847         fields1 = (fields + ['groups_id']) if group_fields else fields
848         values = super(users_view, self).default_get(cr, uid, fields1, context)
849         self._get_reified_groups(group_fields, values)
850         return values
851
852     def read(self, cr, uid, ids, fields=None, context=None, load='_classic_read'):
853         if not fields:
854             fields = self.fields_get(cr, uid, context=context).keys()
855         group_fields, fields = partition(is_reified_group, fields)
856         fields.append('groups_id')
857         res = super(users_view, self).read(cr, uid, ids, fields, context=context, load=load)
858         for values in (res if isinstance(res, list) else [res]):
859             self._get_reified_groups(group_fields, values)
860         return res
861
862     def _get_reified_groups(self, fields, values):
863         """ compute the given reified group fields from values['groups_id'] """
864         gids = set(values.get('groups_id') or [])
865         for f in fields:
866             if is_boolean_group(f):
867                 values[f] = get_boolean_group(f) in gids
868             elif is_boolean_groups(f):
869                 values[f] = not gids.isdisjoint(get_boolean_groups(f))
870             elif is_selection_groups(f):
871                 selected = [gid for gid in get_selection_groups(f) if gid in gids]
872                 values[f] = selected and selected[-1] or False
873
874     def fields_get(self, cr, uid, allfields=None, context=None, write_access=True):
875         res = super(users_view, self).fields_get(cr, uid, allfields, context, write_access)
876         # add reified groups fields
877         for app, kind, gs in self.pool.get('res.groups').get_groups_by_application(cr, uid, context):
878             if kind == 'selection':
879                 # selection group field
880                 tips = ['%s: %s' % (g.name, g.comment or '') for g in gs]
881                 res[name_selection_groups(map(int, gs))] = {
882                     'type': 'selection',
883                     'string': app and app.name or _('Other'),
884                     'selection': [(False, '')] + [(g.id, g.name) for g in gs],
885                     'help': '\n'.join(tips),
886                 }
887             else:
888                 # boolean group fields
889                 for g in gs:
890                     res[name_boolean_group(g.id)] = {
891                         'type': 'boolean',
892                         'string': g.name,
893                         'help': g.comment,
894                     }
895         return res
896
897 users_view()
898
899 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: