1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2004-2009 Tiny SPRL (<http://tiny.be>).
6 # Copyright (C) 2010-2011 OpenERP s.a. (<http://openerp.com>).
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.
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.
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/>.
21 ##############################################################################
24 from functools import partial
31 from osv import fields,osv
32 from osv.orm import browse_record
33 from service import security
34 from tools.translate import _
36 import openerp.exceptions
41 _logger = logging.getLogger(__name__)
43 class groups(osv.osv):
45 _description = "Access Groups"
46 _rec_name = 'full_name'
48 def _get_full_name(self, cr, uid, ids, field, arg, context=None):
50 for g in self.browse(cr, uid, ids, context):
52 res[g.id] = '%s / %s' % (g.category_id.name, g.name)
58 'name': fields.char('Name', size=64, required=True, translate=True),
59 'users': fields.many2many('res.users', 'res_groups_users_rel', 'gid', 'uid', 'Users'),
60 'model_access': fields.one2many('ir.model.access', 'group_id', 'Access Controls'),
61 'rule_groups': fields.many2many('ir.rule', 'rule_group_rel',
62 'group_id', 'rule_group_id', 'Rules', domain=[('global', '=', False)]),
63 'menu_access': fields.many2many('ir.ui.menu', 'ir_ui_menu_group_rel', 'gid', 'menu_id', 'Access Menu'),
64 'comment' : fields.text('Comment', size=250, translate=True),
65 'category_id': fields.many2one('ir.module.category', 'Application', select=True),
66 'full_name': fields.function(_get_full_name, type='char', string='Group Name'),
70 ('name_uniq', 'unique (category_id, name)', 'The name of the group must be unique !')
73 def search(self, cr, uid, args, offset=0, limit=None, order=None, context=None, count=False):
74 # add explicit ordering if search is sorted on full_name
75 if order and order.startswith('full_name'):
76 ids = super(groups, self).search(cr, uid, args, context=context)
77 gs = self.browse(cr, uid, ids, context)
78 gs.sort(key=lambda g: g.full_name, reverse=order.endswith('DESC'))
79 gs = gs[offset:offset+limit] if limit else gs[offset:]
81 return super(groups, self).search(cr, uid, args, offset, limit, order, context, count)
83 def copy(self, cr, uid, id, default=None, context=None):
84 group_name = self.read(cr, uid, [id], ['name'])[0]['name']
85 default.update({'name': _('%s (copy)')%group_name})
86 return super(groups, self).copy(cr, uid, id, default, context)
88 def write(self, cr, uid, ids, vals, context=None):
90 if vals['name'].startswith('-'):
91 raise osv.except_osv(_('Error'),
92 _('The name of the group can not start with "-"'))
93 res = super(groups, self).write(cr, uid, ids, vals, context=context)
94 self.pool.get('ir.model.access').call_cache_clearing_methods(cr)
97 def create(self, cr, uid, vals, context=None):
99 if vals['name'].startswith('-'):
100 raise osv.except_osv(_('Error'),
101 _('The name of the group can not start with "-"'))
102 gid = super(groups, self).create(cr, uid, vals, context=context)
103 if context and context.get('noadmin', False):
106 # assign this new group to user_root
107 user_obj = self.pool.get('res.users')
108 aid = user_obj.browse(cr, 1, user_obj._get_admin_id(cr))
110 aid.write({'groups_id': [(4, gid)]})
113 def unlink(self, cr, uid, ids, context=None):
115 for record in self.read(cr, uid, ids, ['users'], context=context):
117 group_users.extend(record['users'])
119 user_names = [user.name for user in self.pool.get('res.users').browse(cr, uid, group_users, context=context)]
120 user_names = list(set(user_names))
121 if len(user_names) >= 5:
122 user_names = user_names[:5] + ['...']
123 raise osv.except_osv(_('Warning !'),
124 _('Group(s) cannot be deleted, because some user(s) still belong to them: %s !') % \
125 ', '.join(user_names))
126 return super(groups, self).unlink(cr, uid, ids, context=context)
128 def get_extended_interface_group(self, cr, uid, context=None):
129 data_obj = self.pool.get('ir.model.data')
130 extended_group_data_id = data_obj._get_id(cr, uid, 'base', 'group_extended')
131 return data_obj.browse(cr, uid, extended_group_data_id, context=context).res_id
135 def _lang_get(self, cr, uid, context=None):
136 obj = self.pool.get('res.lang')
137 ids = obj.search(cr, uid, [('translatable','=',True)])
138 res = obj.read(cr, uid, ids, ['code', 'name'], context=context)
139 res = [(r['code'], r['name']) for r in res]
142 def _tz_get(self,cr,uid, context=None):
143 return [(x, x) for x in pytz.all_timezones]
145 class users(osv.osv):
151 WELCOME_MAIL_SUBJECT = u"Welcome to OpenERP"
152 WELCOME_MAIL_BODY = u"An OpenERP account has been created for you, "\
153 "\"%(name)s\".\n\nYour login is %(login)s, "\
154 "you should ask your supervisor or system administrator if you "\
155 "haven't been given your password yet.\n\n"\
156 "If you aren't %(name)s, this email reached you errorneously, "\
159 def get_welcome_mail_subject(self, cr, uid, context=None):
160 """ Returns the subject of the mail new users receive (when
161 created via the res.config.users wizard), default implementation
162 is to return config_users.WELCOME_MAIL_SUBJECT
164 return self.WELCOME_MAIL_SUBJECT
165 def get_welcome_mail_body(self, cr, uid, context=None):
166 """ Returns the subject of the mail new users receive (when
167 created via the res.config.users wizard), default implementation
168 is to return config_users.WELCOME_MAIL_BODY
170 return self.WELCOME_MAIL_BODY
172 def get_current_company(self, cr, uid):
173 cr.execute('select company_id, res_company.name from res_users left join res_company on res_company.id = company_id where res_users.id=%s' %uid)
176 def send_welcome_email(self, cr, uid, id, context=None):
177 if isinstance(id,list): id = id[0]
178 user = self.read(cr, uid, id, ['email','login','name', 'user_email'], context=context)
179 email = user['email'] or user['user_email']
181 ir_mail_server = self.pool.get('ir.mail_server')
182 msg = ir_mail_server.build_email(email_from=None, # take config default
184 subject=self.get_welcome_mail_subject(cr, uid, context=context),
185 body=(self.get_welcome_mail_body(cr, uid, context=context) % user))
186 return ir_mail_server.send_email(cr, uid, msg, context=context)
188 def _set_interface_type(self, cr, uid, ids, name, value, arg, context=None):
189 """Implementation of 'view' function field setter, sets the type of interface of the users.
190 @param name: Name of the field
191 @param arg: User defined argument
192 @param value: new value returned
195 if not value or value not in ['simple','extended']:
197 group_obj = self.pool.get('res.groups')
198 extended_group_id = group_obj.get_extended_interface_group(cr, uid, context=context)
199 # First always remove the users from the group (avoids duplication if called twice)
200 self.write(cr, uid, ids, {'groups_id': [(3, extended_group_id)]}, context=context)
201 # Then add them back if requested
202 if value == 'extended':
203 self.write(cr, uid, ids, {'groups_id': [(4, extended_group_id)]}, context=context)
206 def _get_avatar_mini(self, cr, uid, ids, name, args, context=None):
208 for obj in self.browse(cr, uid, ids, context=context):
210 result[obj.id] = False
213 image_stream = io.BytesIO(obj.avatar.decode('base64'))
214 img = Image.open(image_stream)
215 img.thumbnail((180, 150), Image.ANTIALIAS)
216 img_stream = StringIO.StringIO()
217 img.save(img_stream, "JPEG")
218 result[obj.id] = img_stream.getvalue().encode('base64')
221 def _set_avatar_mini(self, cr, uid, id, name, value, args, context=None):
222 self.write(cr, uid, [id], {'avatar': value}, context=context)
225 def _get_interface_type(self, cr, uid, ids, name, args, context=None):
226 """Implementation of 'view' function field getter, returns the type of interface of the users.
227 @param field_name: Name of the field
228 @param arg: User defined argument
229 @return: Dictionary of values
231 group_obj = self.pool.get('res.groups')
232 extended_group_id = group_obj.get_extended_interface_group(cr, uid, context=context)
233 extended_users = group_obj.read(cr, uid, extended_group_id, ['users'], context=context)['users']
234 return dict(zip(ids, ['extended' if user in extended_users else 'simple' for user in ids]))
236 def _set_new_password(self, cr, uid, id, name, value, args, context=None):
238 # Do not update the password if no value is provided, ignore silently.
239 # For example web client submits False values for all empty fields.
242 # To change their own password users must use the client-specific change password wizard,
243 # so that the new password is immediately used for further RPC requests, otherwise the user
244 # will face unexpected 'Access Denied' exceptions.
245 raise osv.except_osv(_('Operation Canceled'), _('Please use the change password wizard (in User Preferences or User menu) to change your own password.'))
246 self.write(cr, uid, id, {'password': value})
248 def _get_password(self, cr, uid, ids, arg, karg, context=None):
249 return dict.fromkeys(ids, '')
252 'id': fields.integer('ID'),
253 'name': fields.char('User Name', size=64, required=True, select=True,
254 help="The new user's real name, used for searching"
255 " and most listings"),
256 'login': fields.char('Login', size=64, required=True,
257 help="Used to log into the system"),
258 'password': fields.char('Password', size=64, invisible=True, help="Keep empty if you don't want the user to be able to connect on the system."),
259 'new_password': fields.function(_get_password, type='char', size=64,
260 fnct_inv=_set_new_password,
261 string='Set password', help="Specify a value only when creating a user or if you're changing the user's password, "
262 "otherwise leave empty. After a change of password, the user has to login again."),
263 'user_email': fields.char('Email', size=64),
264 'signature': fields.text('Signature', size=64),
265 'avatar': fields.binary('User Avatar'),
266 'avatar_mini': fields.function(_get_avatar_mini, fnct_inv=_set_avatar_mini, string='User Avatar Mini', type="binary",
268 'res.users': (lambda self, cr, uid, ids, c={}: ids, ['avatar'], 10),
270 'active': fields.boolean('Active'),
271 '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."),
272 'menu_id': fields.many2one('ir.actions.actions', 'Menu Action', help="If specified, the action will replace the standard menu for this user."),
273 'groups_id': fields.many2many('res.groups', 'res_groups_users_rel', 'uid', 'gid', 'Groups'),
275 # Special behavior for this field: res.company.search() will only return the companies
276 # available to the current user (should be the user's companies?), when the user_preference
278 'company_id': fields.many2one('res.company', 'Company', required=True,
279 help="The company this user is currently working for.", context={'user_preference': True}),
281 'company_ids':fields.many2many('res.company','res_company_users_rel','user_id','cid','Companies'),
282 'context_lang': fields.selection(_lang_get, 'Language', required=True,
283 help="The default language used in the graphical user interface, when translations are available. To add a new language, you can use the 'Load an Official Translation' wizard available from the 'Administration' menu."),
284 'context_tz': fields.selection(_tz_get, 'Timezone', size=64,
285 help="The user's timezone, used to output proper date and time values inside printed reports. "
286 "It is important to set a value for this field. You should use the same timezone "
287 "that is otherwise used to pick and render date and time values: your computer's timezone."),
288 'view': fields.function(_get_interface_type, type='selection', fnct_inv=_set_interface_type,
289 selection=[('simple','Simplified'),('extended','Extended')],
290 string='Interface', help="OpenERP offers a simplified and an extended user interface. If you use OpenERP for the first time we strongly advise you to select the simplified interface, which has less features but is easier to use. You can switch to the other interface from the User/Preferences menu at any time."),
291 'menu_tips': fields.boolean('Menu Tips', help="Check out this box if you want to always display tips on each menu action"),
292 'date': fields.datetime('Latest Connection', readonly=True),
295 def on_change_company_id(self, cr, uid, ids, company_id):
298 'title': _("Company Switch Warning"),
299 '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)"),
303 def read(self,cr, uid, ids, fields=None, context=None, load='_classic_read'):
304 def override_password(o):
305 if 'password' in o and ( 'id' not in o or o['id'] != uid ):
306 o['password'] = '********'
308 result = super(users, self).read(cr, uid, ids, fields, context, load)
309 canwrite = self.pool.get('ir.model.access').check(cr, uid, 'res.users', 'write', False)
311 if isinstance(ids, (int, float)):
312 result = override_password(result)
314 result = map(override_password, result)
318 def _check_company(self, cr, uid, ids, context=None):
319 return all(((this.company_id in this.company_ids) or not this.company_ids) for this in self.browse(cr, uid, ids, context))
322 (_check_company, 'The chosen company is not in the allowed companies for this user', ['company_id', 'company_ids']),
326 ('login_key', 'UNIQUE (login)', 'You can not have two users with the same login !')
329 def _get_email_from(self, cr, uid, ids, context=None):
330 if not isinstance(ids, list):
332 res = dict.fromkeys(ids, False)
333 for user in self.browse(cr, uid, ids, context=context):
335 res[user.id] = "%s <%s>" % (user.name, user.user_email)
338 def _get_admin_id(self, cr):
339 if self.__admin_ids.get(cr.dbname) is None:
340 ir_model_data_obj = self.pool.get('ir.model.data')
341 mdid = ir_model_data_obj._get_id(cr, 1, 'base', 'user_root')
342 self.__admin_ids[cr.dbname] = ir_model_data_obj.read(cr, 1, [mdid], ['res_id'])[0]['res_id']
343 return self.__admin_ids[cr.dbname]
345 def _get_company(self,cr, uid, context=None, uid2=False):
348 user = self.pool.get('res.users').read(cr, uid, uid2, ['company_id'], context)
349 company_id = user.get('company_id', False)
350 return company_id and company_id[0] or False
352 def _get_companies(self, cr, uid, context=None):
353 c = self._get_company(cr, uid, context)
358 def _get_menu(self,cr, uid, context=None):
359 dataobj = self.pool.get('ir.model.data')
361 model, res_id = dataobj.get_object_reference(cr, uid, 'base', 'action_menu_admin')
362 if model != 'ir.actions.act_window':
368 def _get_group(self,cr, uid, context=None):
369 dataobj = self.pool.get('ir.model.data')
372 dummy,group_id = dataobj.get_object_reference(cr, 1, 'base', 'group_user')
373 result.append(group_id)
374 dummy,group_id = dataobj.get_object_reference(cr, 1, 'base', 'group_partner_manager')
375 result.append(group_id)
377 # If these groups does not exists anymore
381 def _get_avatar(self, cr, uid, context=None):
382 avatar_path = openerp.modules.get_module_resource('base','images','photo.png')
383 return open(avatar_path, 'rb').read().encode('base64')
387 'context_lang': 'en_US',
388 'avatar': _get_avatar,
390 'menu_id': _get_menu,
391 'company_id': _get_company,
392 'company_ids': _get_companies,
393 'groups_id': _get_group,
397 # User can write to a few of her own fields (but not her groups for example)
398 SELF_WRITEABLE_FIELDS = ['menu_tips','view', 'password', 'signature', 'action_id', 'company_id', 'user_email', 'name']
400 def write(self, cr, uid, ids, values, context=None):
401 if not hasattr(ids, '__iter__'):
404 for key in values.keys():
405 if not (key in self.SELF_WRITEABLE_FIELDS or key.startswith('context_')):
408 if 'company_id' in values:
409 if not (values['company_id'] in self.read(cr, 1, uid, ['company_ids'], context=context)['company_ids']):
410 del values['company_id']
411 uid = 1 # safe fields only, so we write as super-user to bypass access rights
413 res = super(users, self).write(cr, uid, ids, values, context=context)
415 # clear caches linked to the users
416 self.pool.get('ir.model.access').call_cache_clearing_methods(cr)
417 clear = partial(self.pool.get('ir.rule').clear_cache, cr)
420 if db in self._uid_cache:
422 if id in self._uid_cache[db]:
423 del self._uid_cache[db][id]
427 def unlink(self, cr, uid, ids, context=None):
429 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, ...)'))
431 if db in self._uid_cache:
433 if id in self._uid_cache[db]:
434 del self._uid_cache[db][id]
435 return super(users, self).unlink(cr, uid, ids, context=context)
437 def name_search(self, cr, user, name='', args=None, operator='ilike', context=None, limit=100):
444 ids = self.search(cr, user, [('login','=',name)]+ args, limit=limit)
446 ids = self.search(cr, user, [('name',operator,name)]+ args, limit=limit)
447 return self.name_get(cr, user, ids)
449 def copy(self, cr, uid, id, default=None, context=None):
450 user2copy = self.read(cr, uid, [id], ['login','name'])[0]
453 copy_pattern = _("%s (copy)")
454 copydef = dict(login=(copy_pattern % user2copy['login']),
455 name=(copy_pattern % user2copy['name']),
457 copydef.update(default)
458 return super(users, self).copy(cr, uid, id, copydef, context)
460 def context_get(self, cr, uid, context=None):
461 user = self.browse(cr, uid, uid, context)
463 for k in self._columns.keys():
464 if k.startswith('context_'):
465 res = getattr(user,k) or False
466 if isinstance(res, browse_record):
468 result[k[8:]] = res or False
471 def action_get(self, cr, uid, context=None):
472 dataobj = self.pool.get('ir.model.data')
473 data_id = dataobj._get_id(cr, 1, 'base', 'action_res_users_my')
474 return dataobj.browse(cr, uid, data_id, context=context).res_id
476 def authenticate(self, db, login, password, user_agent_env):
477 """Verifies and returns the user ID corresponding to the given
478 ``login`` and ``password`` combination, or False if there was
481 :param str db: the database on which user is trying to authenticate
482 :param str login: username
483 :param str password: user password
484 :param dict user_agent_env: environment dictionary describing any
485 relevant environment attributes
487 uid = self.login(db, login, password)
488 if uid == openerp.SUPERUSER_ID:
489 # Successfully logged in as admin!
490 # Attempt to guess the web base url...
491 if user_agent_env and user_agent_env.get('base_location'):
492 cr = pooler.get_db(db).cursor()
494 self.pool.get('ir.config_parameter').set_param(cr, uid, 'web.base.url',
495 user_agent_env['base_location'])
498 _logger.exception("Failed to update web.base.url configuration parameter")
503 def login(self, db, login, password):
506 cr = pooler.get_db(db).cursor()
508 # autocommit: our single request will be performed atomically.
509 # (In this way, there is no opportunity to have two transactions
510 # interleaving their cr.execute()..cr.commit() calls and have one
511 # of them rolled back due to a concurrent access.)
512 # We effectively unconditionally write the res_users line.
514 # Even w/ autocommit there's a chance the user row will be locked,
515 # in which case we can't delay the login just for the purpose of
516 # update the last login date - hence we use FOR UPDATE NOWAIT to
517 # try to get the lock - fail-fast
518 cr.execute("""SELECT id from res_users
519 WHERE login=%s AND password=%s
520 AND active FOR UPDATE NOWAIT""",
521 (tools.ustr(login), tools.ustr(password)))
522 cr.execute("""UPDATE res_users
523 SET date = now() AT TIME ZONE 'UTC'
524 WHERE login=%s AND password=%s AND active
526 (tools.ustr(login), tools.ustr(password)))
528 # Failing to acquire the lock on the res_users row probably means
529 # another request is holding it. No big deal, we don't want to
530 # prevent/delay login in that case. It will also have been logged
531 # as a SQL error, if anyone cares.
532 cr.execute("""SELECT id from res_users
533 WHERE login=%s AND password=%s
535 (tools.ustr(login), tools.ustr(password)))
543 def check_super(self, passwd):
544 if passwd == tools.config['admin_passwd']:
547 raise openerp.exceptions.AccessDenied()
549 def check(self, db, uid, passwd):
550 """Verifies that the given (uid, password) pair is authorized for the database ``db`` and
551 raise an exception if it is not."""
553 # empty passwords disallowed for obvious security reasons
554 raise openerp.exceptions.AccessDenied()
555 if self._uid_cache.get(db, {}).get(uid) == passwd:
557 cr = pooler.get_db(db).cursor()
559 cr.execute('SELECT COUNT(1) FROM res_users WHERE id=%s AND password=%s AND active=%s',
560 (int(uid), passwd, True))
561 res = cr.fetchone()[0]
563 raise openerp.exceptions.AccessDenied()
564 if self._uid_cache.has_key(db):
565 ulist = self._uid_cache[db]
568 self._uid_cache[db] = {uid:passwd}
572 def access(self, db, uid, passwd, sec_level, ids):
575 cr = pooler.get_db(db).cursor()
577 cr.execute('SELECT id FROM res_users WHERE id=%s AND password=%s', (uid, passwd))
580 raise openerp.exceptions.AccessDenied()
585 def change_password(self, cr, uid, old_passwd, new_passwd, context=None):
586 """Change current user password. Old password must be provided explicitly
587 to prevent hijacking an existing user session, or for cases where the cleartext
588 password is not used to authenticate requests.
591 :raise: openerp.exceptions.AccessDenied when old password is wrong
592 :raise: except_osv when new password is not set or empty
594 self.check(cr.dbname, uid, old_passwd)
596 return self.write(cr, uid, uid, {'password': new_passwd})
597 raise osv.except_osv(_('Warning!'), _("Setting empty passwords is not allowed for security reasons!"))
604 # Extension of res.groups and res.users with a relation for "implied" or
605 # "inherited" groups. Once a user belongs to a group, it automatically belongs
606 # to the implied groups (transitively).
610 """ A cset (constrained set) is a set of elements that may be constrained to
611 be a subset of other csets. Elements added to a cset are automatically
612 added to its supersets. Cycles in the subset constraints are supported.
614 def __init__(self, xs):
615 self.supersets = set()
616 self.elements = set(xs)
617 def subsetof(self, other):
618 if other is not self:
619 self.supersets.add(other)
620 other.update(self.elements)
621 def update(self, xs):
622 xs = set(xs) - self.elements
623 if xs: # xs will eventually be empty in case of a cycle
624 self.elements.update(xs)
625 for s in self.supersets:
628 return iter(self.elements)
631 """ return the concatenation of a list of iterables """
633 for l in ls: res.extend(l)
638 class groups_implied(osv.osv):
639 _inherit = 'res.groups'
641 def _get_trans_implied(self, cr, uid, ids, field, arg, context=None):
642 "computes the transitive closure of relation implied_ids"
643 memo = {} # use a memo for performance and cycle avoidance
646 memo[g] = cset(g.implied_ids)
647 for h in g.implied_ids:
648 computed_set(h).subsetof(memo[g])
652 for g in self.browse(cr, 1, ids, context):
653 res[g.id] = map(int, computed_set(g))
657 'implied_ids': fields.many2many('res.groups', 'res_groups_implied_rel', 'gid', 'hid',
658 string='Inherits', help='Users of this group automatically inherit those groups'),
659 'trans_implied_ids': fields.function(_get_trans_implied,
660 type='many2many', relation='res.groups', string='Transitively inherits'),
663 def create(self, cr, uid, values, context=None):
664 users = values.pop('users', None)
665 gid = super(groups_implied, self).create(cr, uid, values, context)
667 # delegate addition of users to add implied groups
668 self.write(cr, uid, [gid], {'users': users}, context)
671 def write(self, cr, uid, ids, values, context=None):
672 res = super(groups_implied, self).write(cr, uid, ids, values, context)
673 if values.get('users') or values.get('implied_ids'):
674 # add all implied groups (to all users of each group)
675 for g in self.browse(cr, uid, ids):
676 gids = map(int, g.trans_implied_ids)
677 vals = {'users': [(4, u.id) for u in g.users]}
678 super(groups_implied, self).write(cr, uid, gids, vals, context)
683 class users_implied(osv.osv):
684 _inherit = 'res.users'
686 def create(self, cr, uid, values, context=None):
687 groups = values.pop('groups_id', None)
688 user_id = super(users_implied, self).create(cr, uid, values, context)
690 # delegate addition of groups to add implied groups
691 self.write(cr, uid, [user_id], {'groups_id': groups}, context)
694 def write(self, cr, uid, ids, values, context=None):
695 if not isinstance(ids,list):
697 res = super(users_implied, self).write(cr, uid, ids, values, context)
698 if values.get('groups_id'):
699 # add implied groups for all users
700 for user in self.browse(cr, uid, ids):
701 gs = set(concat([g.trans_implied_ids for g in user.groups_id]))
702 vals = {'groups_id': [(4, g.id) for g in gs]}
703 super(users_implied, self).write(cr, uid, [user.id], vals, context)
711 # Extension of res.groups and res.users for the special groups view in the users
712 # form. This extension presents groups with selection and boolean widgets:
713 # - Groups are shown by application, with boolean and/or selection fields.
714 # Selection fields typically defines a role "Name" for the given application.
715 # - Uncategorized groups are presented as boolean fields and grouped in a
718 # The user form view is modified by an inherited view (base.user_groups_view);
719 # the inherited view replaces the field 'groups_id' by a set of reified group
720 # fields (boolean or selection fields). The arch of that view is regenerated
721 # each time groups are changed.
723 # Naming conventions for reified groups fields:
724 # - boolean field 'in_group_ID' is True iff
725 # ID is in 'groups_id'
726 # - boolean field 'in_groups_ID1_..._IDk' is True iff
727 # any of ID1, ..., IDk is in 'groups_id'
728 # - selection field 'sel_groups_ID1_..._IDk' is ID iff
729 # ID is in 'groups_id' and ID is maximal in the set {ID1, ..., IDk}
731 def name_boolean_group(id): return 'in_group_' + str(id)
732 def name_boolean_groups(ids): return 'in_groups_' + '_'.join(map(str, ids))
733 def name_selection_groups(ids): return 'sel_groups_' + '_'.join(map(str, ids))
735 def is_boolean_group(name): return name.startswith('in_group_')
736 def is_boolean_groups(name): return name.startswith('in_groups_')
737 def is_selection_groups(name): return name.startswith('sel_groups_')
738 def is_reified_group(name):
739 return is_boolean_group(name) or is_boolean_groups(name) or is_selection_groups(name)
741 def get_boolean_group(name): return int(name[9:])
742 def get_boolean_groups(name): return map(int, name[10:].split('_'))
743 def get_selection_groups(name): return map(int, name[11:].split('_'))
745 def partition(f, xs):
746 "return a pair equivalent to (filter(f, xs), filter(lambda x: not f(x), xs))"
749 (yes if f(x) else nos).append(x)
754 class groups_view(osv.osv):
755 _inherit = 'res.groups'
757 def create(self, cr, uid, values, context=None):
758 res = super(groups_view, self).create(cr, uid, values, context)
759 self.update_user_groups_view(cr, uid, context)
762 def write(self, cr, uid, ids, values, context=None):
763 res = super(groups_view, self).write(cr, uid, ids, values, context)
764 self.update_user_groups_view(cr, uid, context)
767 def unlink(self, cr, uid, ids, context=None):
768 res = super(groups_view, self).unlink(cr, uid, ids, context)
769 self.update_user_groups_view(cr, uid, context)
772 def update_user_groups_view(self, cr, uid, context=None):
773 # the view with id 'base.user_groups_view' inherits the user form view,
774 # and introduces the reified group fields
775 view = self.get_user_groups_view(cr, uid, context)
777 xml = u"""<?xml version="1.0" encoding="utf-8"?>
778 <!-- GENERATED AUTOMATICALLY BY GROUPS -->
779 <field name="groups_id" position="replace">
785 xml1.append('<separator string="%s" colspan="4"/>' % _('Applications'))
786 for app, kind, gs in self.get_groups_by_application(cr, uid, context):
787 if kind == 'selection':
788 # application name with a selection field
789 field_name = name_selection_groups(map(int, gs))
790 xml1.append('<field name="%s"/>' % field_name)
791 xml1.append('<newline/>')
793 # application separator with boolean fields
794 app_name = app and app.name or _('Other')
795 xml2.append('<separator string="%s" colspan="4"/>' % app_name)
797 field_name = name_boolean_group(g.id)
798 xml2.append('<field name="%s"/>' % field_name)
799 view.write({'arch': xml % ('\n'.join(xml1), '\n'.join(xml2))})
802 def get_user_groups_view(self, cr, uid, context=None):
804 view = self.pool.get('ir.model.data').get_object(cr, 1, 'base', 'user_groups_view', context)
805 assert view and view._table_name == 'ir.ui.view'
810 def get_application_groups(self, cr, uid, domain=None, context=None):
811 return self.search(cr, uid, domain or [])
813 def get_groups_by_application(self, cr, uid, context=None):
814 """ return all groups classified by application (module category), as a list of pairs:
815 [(app, kind, [group, ...]), ...],
816 where app and group are browse records, and kind is either 'boolean' or 'selection'.
817 Applications are given in sequence order. If kind is 'selection', the groups are
818 given in reverse implication order.
822 # determine sequence order: a group should appear after its implied groups
823 order = dict.fromkeys(gs, 0)
825 for h in gs.intersection(g.trans_implied_ids):
827 # check whether order is total, i.e., sequence orders are distinct
828 if len(set(order.itervalues())) == len(gs):
829 return sorted(gs, key=lambda g: order[g])
832 # classify all groups by application
833 gids = self.get_application_groups(cr, uid, context=context)
834 by_app, others = {}, []
835 for g in self.browse(cr, uid, gids, context):
837 by_app.setdefault(g.category_id, []).append(g)
842 apps = sorted(by_app.iterkeys(), key=lambda a: a.sequence or 0)
844 gs = linearized(by_app[app])
846 res.append((app, 'selection', gs))
848 res.append((app, 'boolean', by_app[app]))
850 res.append((False, 'boolean', others))
855 class users_view(osv.osv):
856 _inherit = 'res.users'
858 def create(self, cr, uid, values, context=None):
859 self._set_reified_groups(values)
860 return super(users_view, self).create(cr, uid, values, context)
862 def write(self, cr, uid, ids, values, context=None):
863 self._set_reified_groups(values)
864 return super(users_view, self).write(cr, uid, ids, values, context)
866 def _set_reified_groups(self, values):
867 """ reflect reified group fields in values['groups_id'] """
868 if 'groups_id' in values:
869 # groups are already given, ignore group fields
870 for f in filter(is_reified_group, values.iterkeys()):
875 for f in values.keys():
876 if is_boolean_group(f):
877 target = add if values.pop(f) else remove
878 target.append(get_boolean_group(f))
879 elif is_boolean_groups(f):
880 if not values.pop(f):
881 remove.extend(get_boolean_groups(f))
882 elif is_selection_groups(f):
883 remove.extend(get_selection_groups(f))
884 selected = values.pop(f)
887 # update values *only* if groups are being modified, otherwise
888 # we introduce spurious changes that might break the super.write() call.
890 # remove groups in 'remove' and add groups in 'add'
891 values['groups_id'] = [(3, id) for id in remove] + [(4, id) for id in add]
893 def default_get(self, cr, uid, fields, context=None):
894 group_fields, fields = partition(is_reified_group, fields)
895 fields1 = (fields + ['groups_id']) if group_fields else fields
896 values = super(users_view, self).default_get(cr, uid, fields1, context)
897 self._get_reified_groups(group_fields, values)
900 def read(self, cr, uid, ids, fields=None, context=None, load='_classic_read'):
902 fields = self.fields_get(cr, uid, context=context).keys()
903 group_fields, fields = partition(is_reified_group, fields)
904 if not 'groups_id' in fields:
905 fields.append('groups_id')
906 res = super(users_view, self).read(cr, uid, ids, fields, context=context, load=load)
907 for values in (res if isinstance(res, list) else [res]):
908 self._get_reified_groups(group_fields, values)
911 def _get_reified_groups(self, fields, values):
912 """ compute the given reified group fields from values['groups_id'] """
913 gids = set(values.get('groups_id') or [])
915 if is_boolean_group(f):
916 values[f] = get_boolean_group(f) in gids
917 elif is_boolean_groups(f):
918 values[f] = not gids.isdisjoint(get_boolean_groups(f))
919 elif is_selection_groups(f):
920 selected = [gid for gid in get_selection_groups(f) if gid in gids]
921 values[f] = selected and selected[-1] or False
923 def fields_get(self, cr, uid, allfields=None, context=None, write_access=True):
924 res = super(users_view, self).fields_get(cr, uid, allfields, context, write_access)
925 # add reified groups fields
926 for app, kind, gs in self.pool.get('res.groups').get_groups_by_application(cr, uid, context):
927 if kind == 'selection':
928 # selection group field
929 tips = ['%s: %s' % (g.name, g.comment or '') for g in gs]
930 res[name_selection_groups(map(int, gs))] = {
932 'string': app and app.name or _('Other'),
933 'selection': [(False, '')] + [(g.id, g.name) for g in gs],
934 'help': '\n'.join(tips),
937 # boolean group fields
939 res[name_boolean_group(g.id)] = {
948 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: