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