[REV] users: contraints on login = email style not applicable anymore.
[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-2013 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 from functools import partial
23 import logging
24 from lxml import etree
25 from lxml.builder import E
26
27 import openerp
28 from openerp import SUPERUSER_ID
29 from openerp import tools
30 import openerp.exceptions
31 from openerp.osv import fields,osv
32 from openerp.osv.orm import browse_record
33 from openerp.tools.translate import _
34
35 _logger = logging.getLogger(__name__)
36
37 class groups(osv.osv):
38     _name = "res.groups"
39     _description = "Access Groups"
40     _rec_name = 'full_name'
41
42     def _get_full_name(self, cr, uid, ids, field, arg, context=None):
43         res = {}
44         for g in self.browse(cr, uid, ids, context):
45             if g.category_id:
46                 res[g.id] = '%s / %s' % (g.category_id.name, g.name)
47             else:
48                 res[g.id] = g.name
49         return res
50
51     def _search_group(self, cr, uid, obj, name, args, context=None):
52         operand = args[0][2]
53         operator = args[0][1]
54         values = operand.split('/')
55         group_name = values[0]
56         where = [('name', operator, group_name)]
57         if len(values) > 1:
58             application_name = values[0]
59             group_name = values[1]
60             where = ['|',('category_id.name', operator, application_name)] + where
61         return where
62
63     _columns = {
64         'name': fields.char('Name', size=64, required=True, translate=True),
65         'users': fields.many2many('res.users', 'res_groups_users_rel', 'gid', 'uid', 'Users'),
66         'model_access': fields.one2many('ir.model.access', 'group_id', 'Access Controls'),
67         'rule_groups': fields.many2many('ir.rule', 'rule_group_rel',
68             'group_id', 'rule_group_id', 'Rules', domain=[('global', '=', False)]),
69         'menu_access': fields.many2many('ir.ui.menu', 'ir_ui_menu_group_rel', 'gid', 'menu_id', 'Access Menu'),
70         'view_access': fields.many2many('ir.ui.view', 'ir_ui_view_group_rel', 'group_id', 'view_id', 'Views'),
71         'comment' : fields.text('Comment', size=250, translate=True),
72         'category_id': fields.many2one('ir.module.category', 'Application', select=True),
73         'full_name': fields.function(_get_full_name, type='char', string='Group Name', fnct_search=_search_group),
74     }
75
76     _sql_constraints = [
77         ('name_uniq', 'unique (category_id, name)', 'The name of the group must be unique !')
78     ]
79
80     def search(self, cr, uid, args, offset=0, limit=None, order=None, context=None, count=False):
81         # add explicit ordering if search is sorted on full_name
82         if order and order.startswith('full_name'):
83             ids = super(groups, self).search(cr, uid, args, context=context)
84             gs = self.browse(cr, uid, ids, context)
85             gs.sort(key=lambda g: g.full_name, reverse=order.endswith('DESC'))
86             gs = gs[offset:offset+limit] if limit else gs[offset:]
87             return map(int, gs)
88         return super(groups, self).search(cr, uid, args, offset, limit, order, context, count)
89
90     def copy(self, cr, uid, id, default=None, context=None):
91         group_name = self.read(cr, uid, [id], ['name'])[0]['name']
92         default.update({'name': _('%s (copy)')%group_name})
93         return super(groups, self).copy(cr, uid, id, default, context)
94
95     def write(self, cr, uid, ids, vals, context=None):
96         if 'name' in vals:
97             if vals['name'].startswith('-'):
98                 raise osv.except_osv(_('Error'),
99                         _('The name of the group can not start with "-"'))
100         res = super(groups, self).write(cr, uid, ids, vals, context=context)
101         self.pool['ir.model.access'].call_cache_clearing_methods(cr)
102         return res
103
104 groups()
105
106 class res_users(osv.osv):
107     """ User class. A res.users record models an OpenERP user and is different
108         from an employee.
109
110         res.users class now inherits from res.partner. The partner model is
111         used to store the data related to the partner: lang, name, address,
112         avatar, ... The user model is now dedicated to technical data.
113     """
114     __admin_ids = {}
115     _uid_cache = {}
116     _inherits = {
117         'res.partner': 'partner_id',
118     }
119     _name = "res.users"
120     _description = 'Users'
121
122     def _set_new_password(self, cr, uid, id, name, value, args, context=None):
123         if value is False:
124             # Do not update the password if no value is provided, ignore silently.
125             # For example web client submits False values for all empty fields.
126             return
127         if uid == id:
128             # To change their own password users must use the client-specific change password wizard,
129             # so that the new password is immediately used for further RPC requests, otherwise the user
130             # will face unexpected 'Access Denied' exceptions.
131             raise osv.except_osv(_('Operation Canceled'), _('Please use the change password wizard (in User Preferences or User menu) to change your own password.'))
132         self.write(cr, uid, id, {'password': value})
133
134     def _get_password(self, cr, uid, ids, arg, karg, context=None):
135         return dict.fromkeys(ids, '')
136
137     _columns = {
138         'id': fields.integer('ID'),
139         'login_date': fields.date('Latest connection', select=1),
140         'partner_id': fields.many2one('res.partner', required=True,
141             string='Related Partner', ondelete='restrict',
142             help='Partner-related data of the user'),
143         'login': fields.char('Login', size=64, required=True,
144             help="Used to log into the system"),
145         'password': fields.char('Password', size=64, invisible=True,
146             help="Keep empty if you don't want the user to be able to connect on the system."),
147         'new_password': fields.function(_get_password, type='char', size=64,
148             fnct_inv=_set_new_password, string='Set Password',
149             help="Specify a value only when creating a user or if you're "\
150                  "changing the user's password, otherwise leave empty. After "\
151                  "a change of password, the user has to login again."),
152         'signature': fields.text('Signature'),
153         'active': fields.boolean('Active'),
154         '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."),
155         'menu_id': fields.many2one('ir.actions.actions', 'Menu Action', help="If specified, the action will replace the standard menu for this user."),
156         'groups_id': fields.many2many('res.groups', 'res_groups_users_rel', 'uid', 'gid', 'Groups'),
157         # Special behavior for this field: res.company.search() will only return the companies
158         # available to the current user (should be the user's companies?), when the user_preference
159         # context is set.
160         'company_id': fields.many2one('res.company', 'Company', required=True,
161             help='The company this user is currently working for.', context={'user_preference': True}),
162         'company_ids':fields.many2many('res.company','res_company_users_rel','user_id','cid','Companies'),
163         # backward compatibility fields
164         'user_email': fields.related('email', type='char',
165             deprecated='Use the email field instead of user_email. This field will be removed with OpenERP 7.1.'),
166     }
167
168     def on_change_login(self, cr, uid, ids, login, context=None):
169         return {'value': {'email': login}}
170
171     def on_change_company_id(self, cr, uid, ids, company_id):
172         return {'warning' : {
173                     'title': _("Company Switch Warning"),
174                     '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)"),
175                 }
176         }
177
178     def onchange_state(self, cr, uid, ids, state_id, context=None):
179         partner_ids = [user.partner_id.id for user in self.browse(cr, uid, ids, context=context)]
180         return self.pool.get('res.partner').onchange_state(cr, uid, partner_ids, state_id, context=context)
181
182     def onchange_type(self, cr, uid, ids, is_company, context=None):
183         """ Wrapper on the user.partner onchange_type, because some calls to the
184             partner form view applied to the user may trigger the
185             partner.onchange_type method, but applied to the user object.
186         """
187         partner_ids = [user.partner_id.id for user in self.browse(cr, uid, ids, context=context)]
188         return self.pool['res.partner'].onchange_type(cr, uid, partner_ids, is_company, context=context)
189
190     def onchange_address(self, cr, uid, ids, use_parent_address, parent_id, context=None):
191         """ Wrapper on the user.partner onchange_address, because some calls to the
192             partner form view applied to the user may trigger the
193             partner.onchange_type method, but applied to the user object.
194         """
195         partner_ids = [user.partner_id.id for user in self.browse(cr, uid, ids, context=context)]
196         return self.pool['res.partner'].onchange_address(cr, uid, partner_ids, use_parent_address, parent_id, context=context)
197
198     def _check_company(self, cr, uid, ids, context=None):
199         return all(((this.company_id in this.company_ids) or not this.company_ids) for this in self.browse(cr, uid, ids, context))
200
201     _constraints = [
202         (_check_company, 'The chosen company is not in the allowed companies for this user', ['company_id', 'company_ids']),
203     ]
204
205     _sql_constraints = [
206         ('login_key', 'UNIQUE (login)',  'You can not have two users with the same login !')
207     ]
208
209     def _get_company(self,cr, uid, context=None, uid2=False):
210         if not uid2:
211             uid2 = uid
212         user = self.pool['res.users'].read(cr, uid, uid2, ['company_id'], context)
213         company_id = user.get('company_id', False)
214         return company_id and company_id[0] or False
215
216     def _get_companies(self, cr, uid, context=None):
217         c = self._get_company(cr, uid, context)
218         if c:
219             return [c]
220         return False
221
222     def _get_menu(self,cr, uid, context=None):
223         dataobj = self.pool.get('ir.model.data')
224         try:
225             model, res_id = dataobj.get_object_reference(cr, uid, 'base', 'action_menu_admin')
226             if model != 'ir.actions.act_window':
227                 return False
228             return res_id
229         except ValueError:
230             return False
231
232     def _get_group(self,cr, uid, context=None):
233         dataobj = self.pool.get('ir.model.data')
234         result = []
235         try:
236             dummy,group_id = dataobj.get_object_reference(cr, SUPERUSER_ID, 'base', 'group_user')
237             result.append(group_id)
238             dummy,group_id = dataobj.get_object_reference(cr, SUPERUSER_ID, 'base', 'group_partner_manager')
239             result.append(group_id)
240         except ValueError:
241             # If these groups does not exists anymore
242             pass
243         return result
244
245     _defaults = {
246         'password': '',
247         'active': True,
248         'customer': False,
249         'menu_id': _get_menu,
250         'company_id': _get_company,
251         'company_ids': _get_companies,
252         'groups_id': _get_group,
253         'image': lambda self, cr, uid, ctx={}: self.pool['res.partner']._get_default_image(cr, uid, False, ctx, colorize=True),
254     }
255
256     # User can write on a few of his own fields (but not his groups for example)
257     SELF_WRITEABLE_FIELDS = ['password', 'signature', 'action_id', 'company_id', 'email', 'name', 'image', 'image_medium', 'image_small', 'lang', 'tz']
258     # User can read a few of his own fields
259     SELF_READABLE_FIELDS = ['signature', 'company_id', 'login', 'email', 'name', 'image', 'image_medium', 'image_small', 'lang', 'tz', 'tz_offset', 'groups_id', 'partner_id', '__last_update']
260
261     def read(self, cr, uid, ids, fields=None, context=None, load='_classic_read'):
262         def override_password(o):
263             if 'password' in o and ('id' not in o or o['id'] != uid):
264                 o['password'] = '********'
265             return o
266
267         if fields and (ids == [uid] or ids == uid):
268             for key in fields:
269                 if not (key in self.SELF_READABLE_FIELDS or key.startswith('context_')):
270                     break
271             else:
272                 # safe fields only, so we read as super-user to bypass access rights
273                 uid = SUPERUSER_ID
274
275         result = super(res_users, self).read(cr, uid, ids, fields=fields, context=context, load=load)
276         canwrite = self.pool['ir.model.access'].check(cr, uid, 'res.users', 'write', False)
277         if not canwrite:
278             if isinstance(ids, (int, long)):
279                 result = override_password(result)
280             else:
281                 result = map(override_password, result)
282
283         return result
284
285     def write(self, cr, uid, ids, values, context=None):
286         if not hasattr(ids, '__iter__'):
287             ids = [ids]
288         if ids == [uid]:
289             for key in values.keys():
290                 if not (key in self.SELF_WRITEABLE_FIELDS or key.startswith('context_')):
291                     break
292             else:
293                 if 'company_id' in values:
294                     if not (values['company_id'] in self.read(cr, SUPERUSER_ID, uid, ['company_ids'], context=context)['company_ids']):
295                         del values['company_id']
296                 uid = 1 # safe fields only, so we write as super-user to bypass access rights
297
298         res = super(res_users, self).write(cr, uid, ids, values, context=context)
299
300         # clear caches linked to the users
301         self.pool['ir.model.access'].call_cache_clearing_methods(cr)
302         clear = partial(self.pool['ir.rule'].clear_cache, cr)
303         map(clear, ids)
304         db = cr.dbname
305         if db in self._uid_cache:
306             for id in ids:
307                 if id in self._uid_cache[db]:
308                     del self._uid_cache[db][id]
309         self.context_get.clear_cache(self)
310         return res
311
312     def unlink(self, cr, uid, ids, context=None):
313         if 1 in ids:
314             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, ...)'))
315         db = cr.dbname
316         if db in self._uid_cache:
317             for id in ids:
318                 if id in self._uid_cache[db]:
319                     del self._uid_cache[db][id]
320         return super(res_users, self).unlink(cr, uid, ids, context=context)
321
322     def name_search(self, cr, user, name='', args=None, operator='ilike', context=None, limit=100):
323         if not args:
324             args=[]
325         if not context:
326             context={}
327         ids = []
328         if name:
329             ids = self.search(cr, user, [('login','=',name)]+ args, limit=limit, context=context)
330         if not ids:
331             ids = self.search(cr, user, [('name',operator,name)]+ args, limit=limit, context=context)
332         return self.name_get(cr, user, ids, context=context)
333
334     def copy(self, cr, uid, id, default=None, context=None):
335         user2copy = self.read(cr, uid, [id], ['login','name'])[0]
336         default = dict(default or {})
337         if ('name' not in default) and ('partner_id' not in default):
338             default['name'] = _("%s (copy)") % user2copy['name']
339         if 'login' not in default:
340             default['login'] = _("%s (copy)") % user2copy['login']
341         return super(res_users, self).copy(cr, uid, id, default, context)
342
343     @tools.ormcache(skiparg=2)
344     def context_get(self, cr, uid, context=None):
345         user = self.browse(cr, SUPERUSER_ID, uid, context)
346         result = {}
347         for k in self._all_columns.keys():
348             if k.startswith('context_'):
349                 context_key = k[8:]
350             elif k in ['lang', 'tz']:
351                 context_key = k
352             else:
353                 context_key = False
354             if context_key:
355                 res = getattr(user,k) or False
356                 if isinstance(res, browse_record):
357                     res = res.id
358                 result[context_key] = res or False
359         return result
360
361     def action_get(self, cr, uid, context=None):
362         dataobj = self.pool['ir.model.data']
363         data_id = dataobj._get_id(cr, SUPERUSER_ID, 'base', 'action_res_users_my')
364         return dataobj.browse(cr, uid, data_id, context=context).res_id
365
366     def check_super(self, passwd):
367         if passwd == tools.config['admin_passwd']:
368             return True
369         else:
370             raise openerp.exceptions.AccessDenied()
371
372     def check_credentials(self, cr, uid, password):
373         """ Override this method to plug additional authentication methods"""
374         res = self.search(cr, SUPERUSER_ID, [('id','=',uid),('password','=',password)])
375         if not res:
376             raise openerp.exceptions.AccessDenied()
377
378     def login(self, db, login, password):
379         if not password:
380             return False
381         user_id = False
382         cr = self.pool.db.cursor()
383         try:
384             # autocommit: our single update request will be performed atomically.
385             # (In this way, there is no opportunity to have two transactions
386             # interleaving their cr.execute()..cr.commit() calls and have one
387             # of them rolled back due to a concurrent access.)
388             cr.autocommit(True)
389             # check if user exists
390             res = self.search(cr, SUPERUSER_ID, [('login','=',login)])
391             if res:
392                 user_id = res[0]
393                 # check credentials
394                 self.check_credentials(cr, user_id, password)
395                 # We effectively unconditionally write the res_users line.
396                 # Even w/ autocommit there's a chance the user row will be locked,
397                 # in which case we can't delay the login just for the purpose of
398                 # update the last login date - hence we use FOR UPDATE NOWAIT to
399                 # try to get the lock - fail-fast
400                 # Failing to acquire the lock on the res_users row probably means
401                 # another request is holding it. No big deal, we don't want to
402                 # prevent/delay login in that case. It will also have been logged
403                 # as a SQL error, if anyone cares.
404                 try:
405                     cr.execute("SELECT id FROM res_users WHERE id=%s FOR UPDATE NOWAIT", (user_id,), log_exceptions=False)
406                     cr.execute("UPDATE res_users SET login_date = now() AT TIME ZONE 'UTC' WHERE id=%s", (user_id,))
407                 except Exception:
408                     _logger.debug("Failed to update last_login for db:%s login:%s", db, login, exc_info=True)
409         except openerp.exceptions.AccessDenied:
410             _logger.info("Login failed for db:%s login:%s", db, login)
411             user_id = False
412         finally:
413             cr.close()
414
415         return user_id
416
417     def authenticate(self, db, login, password, user_agent_env):
418         """Verifies and returns the user ID corresponding to the given
419           ``login`` and ``password`` combination, or False if there was
420           no matching user.
421
422            :param str db: the database on which user is trying to authenticate
423            :param str login: username
424            :param str password: user password
425            :param dict user_agent_env: environment dictionary describing any
426                relevant environment attributes
427         """
428         uid = self.login(db, login, password)
429         if uid == openerp.SUPERUSER_ID:
430             # Successfully logged in as admin!
431             # Attempt to guess the web base url...
432             if user_agent_env and user_agent_env.get('base_location'):
433                 cr = self.pool.db.cursor()
434                 try:
435                     base = user_agent_env['base_location']
436                     ICP = self.pool['ir.config_parameter']
437                     if not ICP.get_param(cr, uid, 'web.base.url.freeze'):
438                         ICP.set_param(cr, uid, 'web.base.url', base)
439                     cr.commit()
440                 except Exception:
441                     _logger.exception("Failed to update web.base.url configuration parameter")
442                 finally:
443                     cr.close()
444         return uid
445
446     def check(self, db, uid, passwd):
447         """Verifies that the given (uid, password) is authorized for the database ``db`` and
448            raise an exception if it is not."""
449         if not passwd:
450             # empty passwords disallowed for obvious security reasons
451             raise openerp.exceptions.AccessDenied()
452         if self._uid_cache.get(db, {}).get(uid) == passwd:
453             return
454         cr = self.pool.db.cursor()
455         try:
456             self.check_credentials(cr, uid, passwd)
457             if self._uid_cache.has_key(db):
458                 self._uid_cache[db][uid] = passwd
459             else:
460                 self._uid_cache[db] = {uid:passwd}
461         finally:
462             cr.close()
463
464     def change_password(self, cr, uid, old_passwd, new_passwd, context=None):
465         """Change current user password. Old password must be provided explicitly
466         to prevent hijacking an existing user session, or for cases where the cleartext
467         password is not used to authenticate requests.
468
469         :return: True
470         :raise: openerp.exceptions.AccessDenied when old password is wrong
471         :raise: except_osv when new password is not set or empty
472         """
473         self.check(cr.dbname, uid, old_passwd)
474         if new_passwd:
475             return self.write(cr, uid, uid, {'password': new_passwd})
476         raise osv.except_osv(_('Warning!'), _("Setting empty passwords is not allowed for security reasons!"))
477
478     def preference_save(self, cr, uid, ids, context=None):
479         return {
480             'type': 'ir.actions.client',
481             'tag': 'reload',
482         }
483
484     def preference_change_password(self, cr, uid, ids, context=None):
485         return {
486             'type': 'ir.actions.client',
487             'tag': 'change_password',
488             'target': 'new',
489         }
490
491     def has_group(self, cr, uid, group_ext_id):
492         """Checks whether user belongs to given group.
493
494         :param str group_ext_id: external ID (XML ID) of the group.
495            Must be provided in fully-qualified form (``module.ext_id``), as there
496            is no implicit module to use..
497         :return: True if the current user is a member of the group with the
498            given external ID (XML ID), else False.
499         """
500         assert group_ext_id and '.' in group_ext_id, "External ID must be fully qualified"
501         module, ext_id = group_ext_id.split('.')
502         cr.execute("""SELECT 1 FROM res_groups_users_rel WHERE uid=%s AND gid IN
503                         (SELECT res_id FROM ir_model_data WHERE module=%s AND name=%s)""",
504                    (uid, module, ext_id))
505         return bool(cr.fetchone())
506
507
508 #
509 # Extension of res.groups and res.users with a relation for "implied" or
510 # "inherited" groups.  Once a user belongs to a group, it automatically belongs
511 # to the implied groups (transitively).
512 #
513
514 class cset(object):
515     """ A cset (constrained set) is a set of elements that may be constrained to
516         be a subset of other csets.  Elements added to a cset are automatically
517         added to its supersets.  Cycles in the subset constraints are supported.
518     """
519     def __init__(self, xs):
520         self.supersets = set()
521         self.elements = set(xs)
522     def subsetof(self, other):
523         if other is not self:
524             self.supersets.add(other)
525             other.update(self.elements)
526     def update(self, xs):
527         xs = set(xs) - self.elements
528         if xs:      # xs will eventually be empty in case of a cycle
529             self.elements.update(xs)
530             for s in self.supersets:
531                 s.update(xs)
532     def __iter__(self):
533         return iter(self.elements)
534
535 def concat(ls):
536     """ return the concatenation of a list of iterables """
537     res = []
538     for l in ls: res.extend(l)
539     return res
540
541
542
543 class groups_implied(osv.osv):
544     _inherit = 'res.groups'
545
546     def _get_trans_implied(self, cr, uid, ids, field, arg, context=None):
547         "computes the transitive closure of relation implied_ids"
548         memo = {}           # use a memo for performance and cycle avoidance
549         def computed_set(g):
550             if g not in memo:
551                 memo[g] = cset(g.implied_ids)
552                 for h in g.implied_ids:
553                     computed_set(h).subsetof(memo[g])
554             return memo[g]
555
556         res = {}
557         for g in self.browse(cr, SUPERUSER_ID, ids, context):
558             res[g.id] = map(int, computed_set(g))
559         return res
560
561     _columns = {
562         'implied_ids': fields.many2many('res.groups', 'res_groups_implied_rel', 'gid', 'hid',
563             string='Inherits', help='Users of this group automatically inherit those groups'),
564         'trans_implied_ids': fields.function(_get_trans_implied,
565             type='many2many', relation='res.groups', string='Transitively inherits'),
566     }
567
568     def create(self, cr, uid, values, context=None):
569         users = values.pop('users', None)
570         gid = super(groups_implied, self).create(cr, uid, values, context)
571         if users:
572             # delegate addition of users to add implied groups
573             self.write(cr, uid, [gid], {'users': users}, context)
574         return gid
575
576     def write(self, cr, uid, ids, values, context=None):
577         res = super(groups_implied, self).write(cr, uid, ids, values, context)
578         if values.get('users') or values.get('implied_ids'):
579             # add all implied groups (to all users of each group)
580             for g in self.browse(cr, uid, ids):
581                 gids = map(int, g.trans_implied_ids)
582                 vals = {'users': [(4, u.id) for u in g.users]}
583                 super(groups_implied, self).write(cr, uid, gids, vals, context)
584         return res
585
586 class users_implied(osv.osv):
587     _inherit = 'res.users'
588
589     def create(self, cr, uid, values, context=None):
590         groups = values.pop('groups_id', None)
591         user_id = super(users_implied, self).create(cr, uid, values, context)
592         if groups:
593             # delegate addition of groups to add implied groups
594             self.write(cr, uid, [user_id], {'groups_id': groups}, context)
595         return user_id
596
597     def write(self, cr, uid, ids, values, context=None):
598         if not isinstance(ids,list):
599             ids = [ids]
600         res = super(users_implied, self).write(cr, uid, ids, values, context)
601         if values.get('groups_id'):
602             # add implied groups for all users
603             for user in self.browse(cr, uid, ids):
604                 gs = set(concat([g.trans_implied_ids for g in user.groups_id]))
605                 vals = {'groups_id': [(4, g.id) for g in gs]}
606                 super(users_implied, self).write(cr, uid, [user.id], vals, context)
607         return res
608
609 #
610 # Extension of res.groups and res.users for the special groups view in the users
611 # form.  This extension presents groups with selection and boolean widgets:
612 # - Groups are shown by application, with boolean and/or selection fields.
613 #   Selection fields typically defines a role "Name" for the given application.
614 # - Uncategorized groups are presented as boolean fields and grouped in a
615 #   section "Others".
616 #
617 # The user form view is modified by an inherited view (base.user_groups_view);
618 # the inherited view replaces the field 'groups_id' by a set of reified group
619 # fields (boolean or selection fields).  The arch of that view is regenerated
620 # each time groups are changed.
621 #
622 # Naming conventions for reified groups fields:
623 # - boolean field 'in_group_ID' is True iff
624 #       ID is in 'groups_id'
625 # - boolean field 'in_groups_ID1_..._IDk' is True iff
626 #       any of ID1, ..., IDk is in 'groups_id'
627 # - selection field 'sel_groups_ID1_..._IDk' is ID iff
628 #       ID is in 'groups_id' and ID is maximal in the set {ID1, ..., IDk}
629
630 def name_boolean_group(id): return 'in_group_' + str(id)
631 def name_boolean_groups(ids): return 'in_groups_' + '_'.join(map(str, ids))
632 def name_selection_groups(ids): return 'sel_groups_' + '_'.join(map(str, ids))
633
634 def is_boolean_group(name): return name.startswith('in_group_')
635 def is_boolean_groups(name): return name.startswith('in_groups_')
636 def is_selection_groups(name): return name.startswith('sel_groups_')
637 def is_reified_group(name):
638     return is_boolean_group(name) or is_boolean_groups(name) or is_selection_groups(name)
639
640 def get_boolean_group(name): return int(name[9:])
641 def get_boolean_groups(name): return map(int, name[10:].split('_'))
642 def get_selection_groups(name): return map(int, name[11:].split('_'))
643
644 def partition(f, xs):
645     "return a pair equivalent to (filter(f, xs), filter(lambda x: not f(x), xs))"
646     yes, nos = [], []
647     for x in xs:
648         (yes if f(x) else nos).append(x)
649     return yes, nos
650
651
652
653 class groups_view(osv.osv):
654     _inherit = 'res.groups'
655
656     def create(self, cr, uid, values, context=None):
657         res = super(groups_view, self).create(cr, uid, values, context)
658         self.update_user_groups_view(cr, uid, context)
659         return res
660
661     def write(self, cr, uid, ids, values, context=None):
662         res = super(groups_view, self).write(cr, uid, ids, values, context)
663         self.update_user_groups_view(cr, uid, context)
664         return res
665
666     def unlink(self, cr, uid, ids, context=None):
667         res = super(groups_view, self).unlink(cr, uid, ids, context)
668         self.update_user_groups_view(cr, uid, context)
669         return res
670
671     def update_user_groups_view(self, cr, uid, context=None):
672         # the view with id 'base.user_groups_view' inherits the user form view,
673         # and introduces the reified group fields
674         view = self.get_user_groups_view(cr, uid, context)
675         if view:
676             xml1, xml2 = [], []
677             xml1.append(E.separator(string=_('Application'), colspan="4"))
678             for app, kind, gs in self.get_groups_by_application(cr, uid, context):
679                 # hide groups in category 'Hidden' (except to group_no_one)
680                 attrs = {'groups': 'base.group_no_one'} if app and app.xml_id == 'base.module_category_hidden' else {}
681                 if kind == 'selection':
682                     # application name with a selection field
683                     field_name = name_selection_groups(map(int, gs))
684                     xml1.append(E.field(name=field_name, **attrs))
685                     xml1.append(E.newline())
686                 else:
687                     # application separator with boolean fields
688                     app_name = app and app.name or _('Other')
689                     xml2.append(E.separator(string=app_name, colspan="4", **attrs))
690                     for g in gs:
691                         field_name = name_boolean_group(g.id)
692                         xml2.append(E.field(name=field_name, **attrs))
693
694             xml = E.field(*(xml1 + xml2), name="groups_id", position="replace")
695             xml.addprevious(etree.Comment("GENERATED AUTOMATICALLY BY GROUPS"))
696             xml_content = etree.tostring(xml, pretty_print=True, xml_declaration=True, encoding="utf-8")
697             view.write({'arch': xml_content})
698         return True
699
700     def get_user_groups_view(self, cr, uid, context=None):
701         try:
702             view = self.pool['ir.model.data'].get_object(cr, SUPERUSER_ID, 'base', 'user_groups_view', context)
703             assert view and view._table_name == 'ir.ui.view'
704         except Exception:
705             view = False
706         return view
707
708     def get_application_groups(self, cr, uid, domain=None, context=None):
709         return self.search(cr, uid, domain or [])
710
711     def get_groups_by_application(self, cr, uid, context=None):
712         """ return all groups classified by application (module category), as a list of pairs:
713                 [(app, kind, [group, ...]), ...],
714             where app and group are browse records, and kind is either 'boolean' or 'selection'.
715             Applications are given in sequence order.  If kind is 'selection', the groups are
716             given in reverse implication order.
717         """
718         def linearized(gs):
719             gs = set(gs)
720             # determine sequence order: a group should appear after its implied groups
721             order = dict.fromkeys(gs, 0)
722             for g in gs:
723                 for h in gs.intersection(g.trans_implied_ids):
724                     order[h] -= 1
725             # check whether order is total, i.e., sequence orders are distinct
726             if len(set(order.itervalues())) == len(gs):
727                 return sorted(gs, key=lambda g: order[g])
728             return None
729
730         # classify all groups by application
731         gids = self.get_application_groups(cr, uid, context=context)
732         by_app, others = {}, []
733         for g in self.browse(cr, uid, gids, context):
734             if g.category_id:
735                 by_app.setdefault(g.category_id, []).append(g)
736             else:
737                 others.append(g)
738         # build the result
739         res = []
740         apps = sorted(by_app.iterkeys(), key=lambda a: a.sequence or 0)
741         for app in apps:
742             gs = linearized(by_app[app])
743             if gs:
744                 res.append((app, 'selection', gs))
745             else:
746                 res.append((app, 'boolean', by_app[app]))
747         if others:
748             res.append((False, 'boolean', others))
749         return res
750
751 class users_view(osv.osv):
752     _inherit = 'res.users'
753
754     def create(self, cr, uid, values, context=None):
755         self._set_reified_groups(values)
756
757         return super(users_view, self).create(cr, uid, values, context)
758
759     def write(self, cr, uid, ids, values, context=None):
760         self._set_reified_groups(values)
761         return super(users_view, self).write(cr, uid, ids, values, context)
762
763     def _set_reified_groups(self, values):
764         """ reflect reified group fields in values['groups_id'] """
765         if 'groups_id' in values:
766             # groups are already given, ignore group fields
767             for f in filter(is_reified_group, values.iterkeys()):
768                 del values[f]
769             return
770
771         add, remove = [], []
772         for f in values.keys():
773             if is_boolean_group(f):
774                 target = add if values.pop(f) else remove
775                 target.append(get_boolean_group(f))
776             elif is_boolean_groups(f):
777                 if not values.pop(f):
778                     remove.extend(get_boolean_groups(f))
779             elif is_selection_groups(f):
780                 remove.extend(get_selection_groups(f))
781                 selected = values.pop(f)
782                 if selected:
783                     add.append(selected)
784         # update values *only* if groups are being modified, otherwise
785         # we introduce spurious changes that might break the super.write() call.
786         if add or remove:
787             # remove groups in 'remove' and add groups in 'add'
788             values['groups_id'] = [(3, id) for id in remove] + [(4, id) for id in add]
789
790     def default_get(self, cr, uid, fields, context=None):
791         group_fields, fields = partition(is_reified_group, fields)
792         fields1 = (fields + ['groups_id']) if group_fields else fields
793         values = super(users_view, self).default_get(cr, uid, fields1, context)
794         self._get_reified_groups(group_fields, values)
795
796         # add "default_groups_ref" inside the context to set default value for group_id with xml values
797         if 'groups_id' in fields and isinstance(context.get("default_groups_ref"), list):
798             groups = []
799             ir_model_data = self.pool.get('ir.model.data')
800             for group_xml_id in context["default_groups_ref"]:
801                 group_split = group_xml_id.split('.')
802                 if len(group_split) != 2:
803                     raise osv.except_osv(_('Invalid context value'), _('Invalid context default_groups_ref value (model.name_id) : "%s"') % group_xml_id)
804                 try:
805                     temp, group_id = ir_model_data.get_object_reference(cr, uid,  group_split[0], group_split[1])
806                 except ValueError:
807                     group_id = False
808                 groups += [group_id]
809             values['groups_id'] = groups
810         return values
811
812     def read(self, cr, uid, ids, fields=None, context=None, load='_classic_read'):
813         if not fields:
814             fields = self.fields_get(cr, uid, context=context).keys()
815         group_fields, fields = partition(is_reified_group, fields)
816         if not 'groups_id' in fields:
817             fields.append('groups_id')
818         res = super(users_view, self).read(cr, uid, ids, fields, context=context, load=load)
819         for values in (res if isinstance(res, list) else [res]):
820             self._get_reified_groups(group_fields, values)
821         return res
822
823     def _get_reified_groups(self, fields, values):
824         """ compute the given reified group fields from values['groups_id'] """
825         gids = set(values.get('groups_id') or [])
826         for f in fields:
827             if is_boolean_group(f):
828                 values[f] = get_boolean_group(f) in gids
829             elif is_boolean_groups(f):
830                 values[f] = not gids.isdisjoint(get_boolean_groups(f))
831             elif is_selection_groups(f):
832                 selected = [gid for gid in get_selection_groups(f) if gid in gids]
833                 values[f] = selected and selected[-1] or False
834
835     def fields_get(self, cr, uid, allfields=None, context=None, write_access=True):
836         res = super(users_view, self).fields_get(cr, uid, allfields, context, write_access)
837         # add reified groups fields
838         for app, kind, gs in self.pool['res.groups'].get_groups_by_application(cr, uid, context):
839             if kind == 'selection':
840                 # selection group field
841                 tips = ['%s: %s' % (g.name, g.comment) for g in gs if g.comment]
842                 res[name_selection_groups(map(int, gs))] = {
843                     'type': 'selection',
844                     'string': app and app.name or _('Other'),
845                     'selection': [(False, '')] + [(g.id, g.name) for g in gs],
846                     'help': '\n'.join(tips),
847                 }
848             else:
849                 # boolean group fields
850                 for g in gs:
851                     res[name_boolean_group(g.id)] = {
852                         'type': 'boolean',
853                         'string': g.name,
854                         'help': g.comment,
855                     }
856         return res
857
858 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: