[IMP] res_group: more explicit constraint error message
[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     @tools.ormcache(skiparg=2)
372     def context_get(self, cr, uid, context=None):
373         user = self.browse(cr, SUPERUSER_ID, uid, context)
374         result = {}
375         for k in self._all_columns.keys():
376             if k.startswith('context_'):
377                 context_key = k[8:]
378             elif k in ['lang', 'tz']:
379                 context_key = k
380             else:
381                 context_key = False
382             if context_key:
383                 res = getattr(user,k) or False
384                 if isinstance(res, browse_record):
385                     res = res.id
386                 result[context_key] = res or False
387         return result
388
389     def action_get(self, cr, uid, context=None):
390         dataobj = self.pool.get('ir.model.data')
391         data_id = dataobj._get_id(cr, SUPERUSER_ID, 'base', 'action_res_users_my')
392         return dataobj.browse(cr, uid, data_id, context=context).res_id
393
394     def check_super(self, passwd):
395         if passwd == tools.config['admin_passwd']:
396             return True
397         else:
398             raise openerp.exceptions.AccessDenied()
399
400     def check_credentials(self, cr, uid, password):
401         """ Override this method to plug additional authentication methods"""
402         res = self.search(cr, SUPERUSER_ID, [('id','=',uid),('password','=',password)])
403         if not res:
404             raise openerp.exceptions.AccessDenied()
405
406     def login(self, db, login, password):
407         if not password:
408             return False
409         user_id = False
410         cr = pooler.get_db(db).cursor()
411         try:
412             # autocommit: our single update request will be performed atomically.
413             # (In this way, there is no opportunity to have two transactions
414             # interleaving their cr.execute()..cr.commit() calls and have one
415             # of them rolled back due to a concurrent access.)
416             cr.autocommit(True)
417             # check if user exists
418             res = self.search(cr, SUPERUSER_ID, [('login','=',login)])
419             if res:
420                 user_id = res[0]
421                 # check credentials
422                 self.check_credentials(cr, user_id, password)
423                 # We effectively unconditionally write the res_users line.
424                 # Even w/ autocommit there's a chance the user row will be locked,
425                 # in which case we can't delay the login just for the purpose of
426                 # update the last login date - hence we use FOR UPDATE NOWAIT to
427                 # try to get the lock - fail-fast
428                 # Failing to acquire the lock on the res_users row probably means
429                 # another request is holding it. No big deal, we don't want to
430                 # prevent/delay login in that case. It will also have been logged
431                 # as a SQL error, if anyone cares.
432                 try:
433                     cr.execute("SELECT id FROM res_users WHERE id=%s FOR UPDATE NOWAIT", (user_id,), log_exceptions=False)
434                     cr.execute("UPDATE res_users SET login_date = now() AT TIME ZONE 'UTC' WHERE id=%s", (user_id,))
435                 except Exception:
436                     _logger.debug("Failed to update last_login for db:%s login:%s", db, login, exc_info=True)
437         except openerp.exceptions.AccessDenied:
438             _logger.info("Login failed for db:%s login:%s", db, login)
439             user_id = False
440         finally:
441             cr.close()
442
443         return user_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                     base = user_agent_env['base_location']
464                     ICP = self.pool.get('ir.config_parameter')
465                     if not ICP.get_param(cr, uid, 'web.base.url.freeze'):
466                         ICP.set_param(cr, uid, 'web.base.url', base)
467                     cr.commit()
468                 except Exception:
469                     _logger.exception("Failed to update web.base.url configuration parameter")
470                 finally:
471                     cr.close()
472         return uid
473
474     def check(self, db, uid, passwd):
475         """Verifies that the given (uid, password) is authorized for the database ``db`` and
476            raise an exception if it is not."""
477         if not passwd:
478             # empty passwords disallowed for obvious security reasons
479             raise openerp.exceptions.AccessDenied()
480         if self._uid_cache.get(db, {}).get(uid) == passwd:
481             return
482         cr = pooler.get_db(db).cursor()
483         try:
484             self.check_credentials(cr, uid, passwd)
485             if self._uid_cache.has_key(db):
486                 self._uid_cache[db][uid] = passwd
487             else:
488                 self._uid_cache[db] = {uid:passwd}
489         finally:
490             cr.close()
491
492     def change_password(self, cr, uid, old_passwd, new_passwd, context=None):
493         """Change current user password. Old password must be provided explicitly
494         to prevent hijacking an existing user session, or for cases where the cleartext
495         password is not used to authenticate requests.
496
497         :return: True
498         :raise: openerp.exceptions.AccessDenied when old password is wrong
499         :raise: except_osv when new password is not set or empty
500         """
501         self.check(cr.dbname, uid, old_passwd)
502         if new_passwd:
503             return self.write(cr, uid, uid, {'password': new_passwd})
504         raise osv.except_osv(_('Warning!'), _("Setting empty passwords is not allowed for security reasons!"))
505
506     def preference_save(self, cr, uid, ids, context=None):
507         return {
508             'type': 'ir.actions.client',
509             'tag': 'reload',
510         }
511
512     def preference_change_password(self, cr, uid, ids, context=None):
513         return {
514             'type': 'ir.actions.client',
515             'tag': 'change_password',
516             'target': 'new',
517         }
518
519     def has_group(self, cr, uid, group_ext_id):
520         """Checks whether user belongs to given group.
521
522         :param str group_ext_id: external ID (XML ID) of the group.
523            Must be provided in fully-qualified form (``module.ext_id``), as there
524            is no implicit module to use..
525         :return: True if the current user is a member of the group with the
526            given external ID (XML ID), else False.
527         """
528         assert group_ext_id and '.' in group_ext_id, "External ID must be fully qualified"
529         module, ext_id = group_ext_id.split('.')
530         cr.execute("""SELECT 1 FROM res_groups_users_rel WHERE uid=%s AND gid IN
531                         (SELECT res_id FROM ir_model_data WHERE module=%s AND name=%s)""",
532                    (uid, module, ext_id))
533         return bool(cr.fetchone())
534
535
536 #
537 # Extension of res.groups and res.users with a relation for "implied" or
538 # "inherited" groups.  Once a user belongs to a group, it automatically belongs
539 # to the implied groups (transitively).
540 #
541
542 class cset(object):
543     """ A cset (constrained set) is a set of elements that may be constrained to
544         be a subset of other csets.  Elements added to a cset are automatically
545         added to its supersets.  Cycles in the subset constraints are supported.
546     """
547     def __init__(self, xs):
548         self.supersets = set()
549         self.elements = set(xs)
550     def subsetof(self, other):
551         if other is not self:
552             self.supersets.add(other)
553             other.update(self.elements)
554     def update(self, xs):
555         xs = set(xs) - self.elements
556         if xs:      # xs will eventually be empty in case of a cycle
557             self.elements.update(xs)
558             for s in self.supersets:
559                 s.update(xs)
560     def __iter__(self):
561         return iter(self.elements)
562
563 def concat(ls):
564     """ return the concatenation of a list of iterables """
565     res = []
566     for l in ls: res.extend(l)
567     return res
568
569
570
571 class groups_implied(osv.osv):
572     _inherit = 'res.groups'
573
574     def _get_trans_implied(self, cr, uid, ids, field, arg, context=None):
575         "computes the transitive closure of relation implied_ids"
576         memo = {}           # use a memo for performance and cycle avoidance
577         def computed_set(g):
578             if g not in memo:
579                 memo[g] = cset(g.implied_ids)
580                 for h in g.implied_ids:
581                     computed_set(h).subsetof(memo[g])
582             return memo[g]
583
584         res = {}
585         for g in self.browse(cr, SUPERUSER_ID, ids, context):
586             res[g.id] = map(int, computed_set(g))
587         return res
588
589     _columns = {
590         'implied_ids': fields.many2many('res.groups', 'res_groups_implied_rel', 'gid', 'hid',
591             string='Inherits', help='Users of this group automatically inherit those groups'),
592         'trans_implied_ids': fields.function(_get_trans_implied,
593             type='many2many', relation='res.groups', string='Transitively inherits'),
594     }
595
596     def create(self, cr, uid, values, context=None):
597         users = values.pop('users', None)
598         gid = super(groups_implied, self).create(cr, uid, values, context)
599         if users:
600             # delegate addition of users to add implied groups
601             self.write(cr, uid, [gid], {'users': users}, context)
602         return gid
603
604     def write(self, cr, uid, ids, values, context=None):
605         res = super(groups_implied, self).write(cr, uid, ids, values, context)
606         if values.get('users') or values.get('implied_ids'):
607             # add all implied groups (to all users of each group)
608             for g in self.browse(cr, uid, ids):
609                 gids = map(int, g.trans_implied_ids)
610                 vals = {'users': [(4, u.id) for u in g.users]}
611                 super(groups_implied, self).write(cr, uid, gids, vals, context)
612         return res
613
614 class users_implied(osv.osv):
615     _inherit = 'res.users'
616
617     def create(self, cr, uid, values, context=None):
618         groups = values.pop('groups_id', None)
619         user_id = super(users_implied, self).create(cr, uid, values, context)
620         if groups:
621             # delegate addition of groups to add implied groups
622             self.write(cr, uid, [user_id], {'groups_id': groups}, context)
623         return user_id
624
625     def write(self, cr, uid, ids, values, context=None):
626         if not isinstance(ids,list):
627             ids = [ids]
628         res = super(users_implied, self).write(cr, uid, ids, values, context)
629         if values.get('groups_id'):
630             # add implied groups for all users
631             for user in self.browse(cr, uid, ids):
632                 gs = set(concat([g.trans_implied_ids for g in user.groups_id]))
633                 vals = {'groups_id': [(4, g.id) for g in gs]}
634                 super(users_implied, self).write(cr, uid, [user.id], vals, context)
635         return res
636
637 #
638 # Extension of res.groups and res.users for the special groups view in the users
639 # form.  This extension presents groups with selection and boolean widgets:
640 # - Groups are shown by application, with boolean and/or selection fields.
641 #   Selection fields typically defines a role "Name" for the given application.
642 # - Uncategorized groups are presented as boolean fields and grouped in a
643 #   section "Others".
644 #
645 # The user form view is modified by an inherited view (base.user_groups_view);
646 # the inherited view replaces the field 'groups_id' by a set of reified group
647 # fields (boolean or selection fields).  The arch of that view is regenerated
648 # each time groups are changed.
649 #
650 # Naming conventions for reified groups fields:
651 # - boolean field 'in_group_ID' is True iff
652 #       ID is in 'groups_id'
653 # - boolean field 'in_groups_ID1_..._IDk' is True iff
654 #       any of ID1, ..., IDk is in 'groups_id'
655 # - selection field 'sel_groups_ID1_..._IDk' is ID iff
656 #       ID is in 'groups_id' and ID is maximal in the set {ID1, ..., IDk}
657
658 def name_boolean_group(id): return 'in_group_' + str(id)
659 def name_boolean_groups(ids): return 'in_groups_' + '_'.join(map(str, ids))
660 def name_selection_groups(ids): return 'sel_groups_' + '_'.join(map(str, ids))
661
662 def is_boolean_group(name): return name.startswith('in_group_')
663 def is_boolean_groups(name): return name.startswith('in_groups_')
664 def is_selection_groups(name): return name.startswith('sel_groups_')
665 def is_reified_group(name):
666     return is_boolean_group(name) or is_boolean_groups(name) or is_selection_groups(name)
667
668 def get_boolean_group(name): return int(name[9:])
669 def get_boolean_groups(name): return map(int, name[10:].split('_'))
670 def get_selection_groups(name): return map(int, name[11:].split('_'))
671
672 def partition(f, xs):
673     "return a pair equivalent to (filter(f, xs), filter(lambda x: not f(x), xs))"
674     yes, nos = [], []
675     for x in xs:
676         (yes if f(x) else nos).append(x)
677     return yes, nos
678
679
680
681 class groups_view(osv.osv):
682     _inherit = 'res.groups'
683
684     def create(self, cr, uid, values, context=None):
685         res = super(groups_view, self).create(cr, uid, values, context)
686         self.update_user_groups_view(cr, uid, context)
687         return res
688
689     def write(self, cr, uid, ids, values, context=None):
690         res = super(groups_view, self).write(cr, uid, ids, values, context)
691         self.update_user_groups_view(cr, uid, context)
692         return res
693
694     def unlink(self, cr, uid, ids, context=None):
695         res = super(groups_view, self).unlink(cr, uid, ids, context)
696         self.update_user_groups_view(cr, uid, context)
697         return res
698
699     def update_user_groups_view(self, cr, uid, context=None):
700         # the view with id 'base.user_groups_view' inherits the user form view,
701         # and introduces the reified group fields
702         view = self.get_user_groups_view(cr, uid, context)
703         if view:
704             xml1, xml2 = [], []
705             xml1.append(E.separator(string=_('Application'), colspan="4"))
706             for app, kind, gs in self.get_groups_by_application(cr, uid, context):
707                 # hide groups in category 'Hidden' (except to group_no_one)
708                 attrs = {'groups': 'base.group_no_one'} if app and app.xml_id == 'base.module_category_hidden' else {}
709                 if kind == 'selection':
710                     # application name with a selection field
711                     field_name = name_selection_groups(map(int, gs))
712                     xml1.append(E.field(name=field_name, **attrs))
713                     xml1.append(E.newline())
714                 else:
715                     # application separator with boolean fields
716                     app_name = app and app.name or _('Other')
717                     xml2.append(E.separator(string=app_name, colspan="4", **attrs))
718                     for g in gs:
719                         field_name = name_boolean_group(g.id)
720                         xml2.append(E.field(name=field_name, **attrs))
721
722             xml = E.field(*(xml1 + xml2), name="groups_id", position="replace")
723             xml.addprevious(etree.Comment("GENERATED AUTOMATICALLY BY GROUPS"))
724             xml_content = etree.tostring(xml, pretty_print=True, xml_declaration=True, encoding="utf-8")
725             view.write({'arch': xml_content})
726         return True
727
728     def get_user_groups_view(self, cr, uid, context=None):
729         try:
730             view = self.pool.get('ir.model.data').get_object(cr, SUPERUSER_ID, 'base', 'user_groups_view', context)
731             assert view and view._table_name == 'ir.ui.view'
732         except Exception:
733             view = False
734         return view
735
736     def get_application_groups(self, cr, uid, domain=None, context=None):
737         return self.search(cr, uid, domain or [])
738
739     def get_groups_by_application(self, cr, uid, context=None):
740         """ return all groups classified by application (module category), as a list of pairs:
741                 [(app, kind, [group, ...]), ...],
742             where app and group are browse records, and kind is either 'boolean' or 'selection'.
743             Applications are given in sequence order.  If kind is 'selection', the groups are
744             given in reverse implication order.
745         """
746         def linearized(gs):
747             gs = set(gs)
748             # determine sequence order: a group should appear after its implied groups
749             order = dict.fromkeys(gs, 0)
750             for g in gs:
751                 for h in gs.intersection(g.trans_implied_ids):
752                     order[h] -= 1
753             # check whether order is total, i.e., sequence orders are distinct
754             if len(set(order.itervalues())) == len(gs):
755                 return sorted(gs, key=lambda g: order[g])
756             return None
757
758         # classify all groups by application
759         gids = self.get_application_groups(cr, uid, context=context)
760         by_app, others = {}, []
761         for g in self.browse(cr, uid, gids, context):
762             if g.category_id:
763                 by_app.setdefault(g.category_id, []).append(g)
764             else:
765                 others.append(g)
766         # build the result
767         res = []
768         apps = sorted(by_app.iterkeys(), key=lambda a: a.sequence or 0)
769         for app in apps:
770             gs = linearized(by_app[app])
771             if gs:
772                 res.append((app, 'selection', gs))
773             else:
774                 res.append((app, 'boolean', by_app[app]))
775         if others:
776             res.append((False, 'boolean', others))
777         return res
778
779 class users_view(osv.osv):
780     _inherit = 'res.users'
781
782     def create(self, cr, uid, values, context=None):
783         self._set_reified_groups(values)
784         return super(users_view, self).create(cr, uid, values, context)
785
786     def write(self, cr, uid, ids, values, context=None):
787         self._set_reified_groups(values)
788         return super(users_view, self).write(cr, uid, ids, values, context)
789
790     def _set_reified_groups(self, values):
791         """ reflect reified group fields in values['groups_id'] """
792         if 'groups_id' in values:
793             # groups are already given, ignore group fields
794             for f in filter(is_reified_group, values.iterkeys()):
795                 del values[f]
796             return
797
798         add, remove = [], []
799         for f in values.keys():
800             if is_boolean_group(f):
801                 target = add if values.pop(f) else remove
802                 target.append(get_boolean_group(f))
803             elif is_boolean_groups(f):
804                 if not values.pop(f):
805                     remove.extend(get_boolean_groups(f))
806             elif is_selection_groups(f):
807                 remove.extend(get_selection_groups(f))
808                 selected = values.pop(f)
809                 if selected:
810                     add.append(selected)
811         # update values *only* if groups are being modified, otherwise
812         # we introduce spurious changes that might break the super.write() call.
813         if add or remove:
814             # remove groups in 'remove' and add groups in 'add'
815             values['groups_id'] = [(3, id) for id in remove] + [(4, id) for id in add]
816
817     def default_get(self, cr, uid, fields, context=None):
818         group_fields, fields = partition(is_reified_group, fields)
819         fields1 = (fields + ['groups_id']) if group_fields else fields
820         values = super(users_view, self).default_get(cr, uid, fields1, context)
821         self._get_reified_groups(group_fields, values)
822         return values
823
824     def read(self, cr, uid, ids, fields=None, context=None, load='_classic_read'):
825         if not fields:
826             fields = self.fields_get(cr, uid, context=context).keys()
827         group_fields, fields = partition(is_reified_group, fields)
828         if not 'groups_id' in fields:
829             fields.append('groups_id')
830         res = super(users_view, self).read(cr, uid, ids, fields, context=context, load=load)
831         if res:
832             for values in (res if isinstance(res, list) else [res]):
833                 self._get_reified_groups(group_fields, values)
834         return res
835
836     def _get_reified_groups(self, fields, values):
837         """ compute the given reified group fields from values['groups_id'] """
838         gids = set(values.get('groups_id') or [])
839         for f in fields:
840             if is_boolean_group(f):
841                 values[f] = get_boolean_group(f) in gids
842             elif is_boolean_groups(f):
843                 values[f] = not gids.isdisjoint(get_boolean_groups(f))
844             elif is_selection_groups(f):
845                 selected = [gid for gid in get_selection_groups(f) if gid in gids]
846                 values[f] = selected and selected[-1] or False
847
848     def fields_get(self, cr, uid, allfields=None, context=None, write_access=True):
849         res = super(users_view, self).fields_get(cr, uid, allfields, context, write_access)
850         # add reified groups fields
851         for app, kind, gs in self.pool.get('res.groups').get_groups_by_application(cr, uid, context):
852             if kind == 'selection':
853                 # selection group field
854                 tips = ['%s: %s' % (g.name, g.comment) for g in gs if g.comment]
855                 res[name_selection_groups(map(int, gs))] = {
856                     'type': 'selection',
857                     'string': app and app.name or _('Other'),
858                     'selection': [(False, '')] + [(g.id, g.name) for g in gs],
859                     'help': '\n'.join(tips),
860                     'exportable': False,
861                     'selectable': False,
862                 }
863             else:
864                 # boolean group fields
865                 for g in gs:
866                     res[name_boolean_group(g.id)] = {
867                         'type': 'boolean',
868                         'string': g.name,
869                         'help': g.comment,
870                         'exportable': False,
871                         'selectable': False,
872                     }
873         return res
874
875 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: