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