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
29 from lxml import etree
30 from lxml.builder import E
33 import openerp.exceptions
34 from osv import fields,osv
35 from osv.orm import browse_record
39 from service import security
41 from tools.translate import _
43 _logger = logging.getLogger(__name__)
45 class groups(osv.osv):
47 _description = "Access Groups"
48 _rec_name = 'full_name'
50 def _get_full_name(self, cr, uid, ids, field, arg, context=None):
52 for g in self.browse(cr, uid, ids, context):
54 res[g.id] = '%s / %s' % (g.category_id.name, g.name)
60 'name': fields.char('Name', size=64, required=True, translate=True),
61 'users': fields.many2many('res.users', 'res_groups_users_rel', 'gid', 'uid', 'Users'),
62 'model_access': fields.one2many('ir.model.access', 'group_id', 'Access Controls'),
63 'rule_groups': fields.many2many('ir.rule', 'rule_group_rel',
64 'group_id', 'rule_group_id', 'Rules', domain=[('global', '=', False)]),
65 'menu_access': fields.many2many('ir.ui.menu', 'ir_ui_menu_group_rel', 'gid', 'menu_id', 'Access Menu'),
66 'comment' : fields.text('Comment', size=250, translate=True),
67 'category_id': fields.many2one('ir.module.category', 'Application', select=True),
68 'full_name': fields.function(_get_full_name, type='char', string='Group Name'),
72 ('name_uniq', 'unique (category_id, name)', 'The name of the group must be unique !')
75 def search(self, cr, uid, args, offset=0, limit=None, order=None, context=None, count=False):
76 # add explicit ordering if search is sorted on full_name
77 if order and order.startswith('full_name'):
78 ids = super(groups, self).search(cr, uid, args, context=context)
79 gs = self.browse(cr, uid, ids, context)
80 gs.sort(key=lambda g: g.full_name, reverse=order.endswith('DESC'))
81 gs = gs[offset:offset+limit] if limit else gs[offset:]
83 return super(groups, self).search(cr, uid, args, offset, limit, order, context, count)
85 def copy(self, cr, uid, id, default=None, context=None):
86 group_name = self.read(cr, uid, [id], ['name'])[0]['name']
87 default.update({'name': _('%s (copy)')%group_name})
88 return super(groups, self).copy(cr, uid, id, default, context)
90 def write(self, cr, uid, ids, vals, context=None):
92 if vals['name'].startswith('-'):
93 raise osv.except_osv(_('Error'),
94 _('The name of the group can not start with "-"'))
95 res = super(groups, self).write(cr, uid, ids, vals, context=context)
96 self.pool.get('ir.model.access').call_cache_clearing_methods(cr)
99 def get_extended_interface_group(self, cr, uid, context=None):
100 data_obj = self.pool.get('ir.model.data')
101 extended_group_data_id = data_obj._get_id(cr, uid, 'base', 'group_extended')
102 return data_obj.browse(cr, uid, extended_group_data_id, context=context).res_id
106 def _lang_get(self, cr, uid, context=None):
107 obj = self.pool.get('res.lang')
108 ids = obj.search(cr, uid, [('translatable','=',True)])
109 res = obj.read(cr, uid, ids, ['code', 'name'], context=context)
110 res = [(r['code'], r['name']) for r in res]
113 def _tz_get(self,cr,uid, context=None):
114 return [(x, x) for x in pytz.all_timezones]
116 class users(osv.osv):
122 WELCOME_MAIL_SUBJECT = u"Welcome to OpenERP"
123 WELCOME_MAIL_BODY = u"An OpenERP account has been created for you, "\
124 "\"%(name)s\".\n\nYour login is %(login)s, "\
125 "you should ask your supervisor or system administrator if you "\
126 "haven't been given your password yet.\n\n"\
127 "If you aren't %(name)s, this email reached you errorneously, "\
130 def get_welcome_mail_subject(self, cr, uid, context=None):
131 """ Returns the subject of the mail new users receive (when
132 created via the res.config.users wizard), default implementation
133 is to return config_users.WELCOME_MAIL_SUBJECT
135 return self.WELCOME_MAIL_SUBJECT
136 def get_welcome_mail_body(self, cr, uid, context=None):
137 """ Returns the subject of the mail new users receive (when
138 created via the res.config.users wizard), default implementation
139 is to return config_users.WELCOME_MAIL_BODY
141 return self.WELCOME_MAIL_BODY
143 def get_current_company(self, cr, uid):
144 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)
147 def send_welcome_email(self, cr, uid, id, context=None):
148 if isinstance(id,list): id = id[0]
149 user = self.read(cr, uid, id, ['email','login','name', 'user_email'], context=context)
150 email = user['email'] or user['user_email']
152 ir_mail_server = self.pool.get('ir.mail_server')
153 msg = ir_mail_server.build_email(email_from=None, # take config default
155 subject=self.get_welcome_mail_subject(cr, uid, context=context),
156 body=(self.get_welcome_mail_body(cr, uid, context=context) % user))
157 return ir_mail_server.send_email(cr, uid, msg, context=context)
159 def _set_interface_type(self, cr, uid, ids, name, value, arg, context=None):
160 """Implementation of 'view' function field setter, sets the type of interface of the users.
161 @param name: Name of the field
162 @param arg: User defined argument
163 @param value: new value returned
166 if not value or value not in ['simple','extended']:
168 group_obj = self.pool.get('res.groups')
169 extended_group_id = group_obj.get_extended_interface_group(cr, uid, context=context)
170 # First always remove the users from the group (avoids duplication if called twice)
171 self.write(cr, uid, ids, {'groups_id': [(3, extended_group_id)]}, context=context)
172 # Then add them back if requested
173 if value == 'extended':
174 self.write(cr, uid, ids, {'groups_id': [(4, extended_group_id)]}, context=context)
177 def _get_interface_type(self, cr, uid, ids, name, args, context=None):
178 """Implementation of 'view' function field getter, returns the type of interface of the users.
179 @param field_name: Name of the field
180 @param arg: User defined argument
181 @return: Dictionary of values
183 group_obj = self.pool.get('res.groups')
184 extended_group_id = group_obj.get_extended_interface_group(cr, uid, context=context)
185 extended_users = group_obj.read(cr, uid, extended_group_id, ['users'], context=context)['users']
186 return dict(zip(ids, ['extended' if user in extended_users else 'simple' for user in ids]))
188 def onchange_avatar(self, cr, uid, ids, value, context=None):
190 return {'value': {'avatar_big': value, 'avatar': value} }
191 return {'value': {'avatar_big': self._avatar_resize(cr, uid, value, 540, 450, context=context), 'avatar': self._avatar_resize(cr, uid, value, context=context)} }
193 def _set_avatar(self, cr, uid, id, name, value, args, context=None):
195 vals = {'avatar_big': value}
197 vals = {'avatar_big': self._avatar_resize(cr, uid, value, 540, 450, context=context)}
198 return self.write(cr, uid, [id], vals, context=context)
200 def _avatar_resize(self, cr, uid, avatar, height=180, width=150, context=None):
201 image_stream = io.BytesIO(avatar.decode('base64'))
202 img = Image.open(image_stream)
203 img.thumbnail((height, width), Image.ANTIALIAS)
204 img_stream = StringIO.StringIO()
205 img.save(img_stream, "PNG")
206 return img_stream.getvalue().encode('base64')
208 def _get_avatar(self, cr, uid, ids, name, args, context=None):
209 result = dict.fromkeys(ids, False)
210 for user in self.browse(cr, uid, ids, context=context):
212 result[user.id] = self._avatar_resize(cr, uid, user.avatar_big, context=context)
215 def _set_new_password(self, cr, uid, id, name, value, args, context=None):
217 # Do not update the password if no value is provided, ignore silently.
218 # For example web client submits False values for all empty fields.
221 # To change their own password users must use the client-specific change password wizard,
222 # so that the new password is immediately used for further RPC requests, otherwise the user
223 # will face unexpected 'Access Denied' exceptions.
224 raise osv.except_osv(_('Operation Canceled'), _('Please use the change password wizard (in User Preferences or User menu) to change your own password.'))
225 self.write(cr, uid, id, {'password': value})
227 def _get_password(self, cr, uid, ids, arg, karg, context=None):
228 return dict.fromkeys(ids, '')
231 'id': fields.integer('ID'),
232 'name': fields.char('User Name', size=64, required=True, select=True,
233 help="The new user's real name, used for searching"
234 " and most listings"),
235 'login': fields.char('Login', size=64, required=True,
236 help="Used to log into the system"),
237 '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."),
238 'new_password': fields.function(_get_password, type='char', size=64,
239 fnct_inv=_set_new_password,
240 string='Set Password', help="Specify a value only when creating a user or if you're changing the user's password, "
241 "otherwise leave empty. After a change of password, the user has to login again."),
242 'user_email': fields.char('Email', size=64),
243 'signature': fields.text('Signature', size=64),
244 'avatar_big': fields.binary('Big-sized avatar', help="This field holds the image used as avatar for the user. The avatar field is used as an interface to access this field. The image is base64 encoded, and PIL-supported. It is stored as a 540x450 px image, in case a bigger image must be used."),
245 'avatar': fields.function(_get_avatar, fnct_inv=_set_avatar, string='Avatar', type="binary",
247 'res.users': (lambda self, cr, uid, ids, c={}: ids, ['avatar_big'], 10),
248 }, help="Image used as avatar for the user. It is automatically resized as a 180x150 px image. This field serves as an interface to the avatar_big field."),
249 'active': fields.boolean('Active'),
250 '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."),
251 'menu_id': fields.many2one('ir.actions.actions', 'Menu Action', help="If specified, the action will replace the standard menu for this user."),
252 'groups_id': fields.many2many('res.groups', 'res_groups_users_rel', 'uid', 'gid', 'Groups'),
254 # Special behavior for this field: res.company.search() will only return the companies
255 # available to the current user (should be the user's companies?), when the user_preference
257 'company_id': fields.many2one('res.company', 'Company', required=True,
258 help="The company this user is currently working for.", context={'user_preference': True}),
260 'company_ids':fields.many2many('res.company','res_company_users_rel','user_id','cid','Companies'),
261 'context_lang': fields.selection(_lang_get, 'Language', required=True,
262 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."),
263 'context_tz': fields.selection(_tz_get, 'Timezone', size=64,
264 help="The user's timezone, used to output proper date and time values inside printed reports. "
265 "It is important to set a value for this field. You should use the same timezone "
266 "that is otherwise used to pick and render date and time values: your computer's timezone."),
267 'view': fields.function(_get_interface_type, type='selection', fnct_inv=_set_interface_type,
268 selection=[('simple','Simplified'),('extended','Extended')],
269 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."),
270 'menu_tips': fields.boolean('Menu Tips', help="Check out this box if you want to always display tips on each menu action"),
271 'date': fields.datetime('Latest Connection', readonly=True),
274 def on_change_company_id(self, cr, uid, ids, company_id):
277 'title': _("Company Switch Warning"),
278 '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)"),
282 def read(self,cr, uid, ids, fields=None, context=None, load='_classic_read'):
283 def override_password(o):
284 if 'password' in o and ( 'id' not in o or o['id'] != uid ):
285 o['password'] = '********'
287 result = super(users, self).read(cr, uid, ids, fields, context, load)
288 canwrite = self.pool.get('ir.model.access').check(cr, uid, 'res.users', 'write', False)
290 if isinstance(ids, (int, float)):
291 result = override_password(result)
293 result = map(override_password, result)
297 def _check_company(self, cr, uid, ids, context=None):
298 return all(((this.company_id in this.company_ids) or not this.company_ids) for this in self.browse(cr, uid, ids, context))
301 (_check_company, 'The chosen company is not in the allowed companies for this user', ['company_id', 'company_ids']),
305 ('login_key', 'UNIQUE (login)', 'You can not have two users with the same login !')
308 def _get_email_from(self, cr, uid, ids, context=None):
309 if not isinstance(ids, list):
311 res = dict.fromkeys(ids, False)
312 for user in self.browse(cr, uid, ids, context=context):
314 res[user.id] = "%s <%s>" % (user.name, user.user_email)
317 def _get_admin_id(self, cr):
318 if self.__admin_ids.get(cr.dbname) is None:
319 ir_model_data_obj = self.pool.get('ir.model.data')
320 mdid = ir_model_data_obj._get_id(cr, 1, 'base', 'user_root')
321 self.__admin_ids[cr.dbname] = ir_model_data_obj.read(cr, 1, [mdid], ['res_id'])[0]['res_id']
322 return self.__admin_ids[cr.dbname]
324 def _get_company(self,cr, uid, context=None, uid2=False):
327 user = self.pool.get('res.users').read(cr, uid, uid2, ['company_id'], context)
328 company_id = user.get('company_id', False)
329 return company_id and company_id[0] or False
331 def _get_companies(self, cr, uid, context=None):
332 c = self._get_company(cr, uid, context)
337 def _get_menu(self,cr, uid, context=None):
338 dataobj = self.pool.get('ir.model.data')
340 model, res_id = dataobj.get_object_reference(cr, uid, 'base', 'action_menu_admin')
341 if model != 'ir.actions.act_window':
347 def _get_group(self,cr, uid, context=None):
348 dataobj = self.pool.get('ir.model.data')
351 dummy,group_id = dataobj.get_object_reference(cr, 1, 'base', 'group_user')
352 result.append(group_id)
353 dummy,group_id = dataobj.get_object_reference(cr, 1, 'base', 'group_partner_manager')
354 result.append(group_id)
356 # If these groups does not exists anymore
360 def _get_avatar(self, cr, uid, context=None):
361 # default avatar file name: avatar0 -> avatar6.png, choose randomly
362 avatar_path = openerp.modules.get_module_resource('base', 'static/src/img', 'avatar%d.png' % random.randint(0, 6))
363 return self._avatar_resize(cr, uid, open(avatar_path, 'rb').read().encode('base64'), context=context)
367 'context_lang': 'en_US',
368 'avatar': _get_avatar,
370 'menu_id': _get_menu,
371 'company_id': _get_company,
372 'company_ids': _get_companies,
373 'groups_id': _get_group,
377 # User can write to a few of her own fields (but not her groups for example)
378 SELF_WRITEABLE_FIELDS = ['menu_tips','view', 'password', 'signature', 'action_id', 'company_id', 'user_email', 'name', 'avatar', 'avatar_big']
380 def write(self, cr, uid, ids, values, context=None):
381 if not hasattr(ids, '__iter__'):
384 for key in values.keys():
385 if not (key in self.SELF_WRITEABLE_FIELDS or key.startswith('context_')):
388 if 'company_id' in values:
389 if not (values['company_id'] in self.read(cr, 1, uid, ['company_ids'], context=context)['company_ids']):
390 del values['company_id']
391 uid = 1 # safe fields only, so we write as super-user to bypass access rights
393 res = super(users, self).write(cr, uid, ids, values, context=context)
395 # clear caches linked to the users
396 self.pool.get('ir.model.access').call_cache_clearing_methods(cr)
397 clear = partial(self.pool.get('ir.rule').clear_cache, cr)
400 if db in self._uid_cache:
402 if id in self._uid_cache[db]:
403 del self._uid_cache[db][id]
407 def unlink(self, cr, uid, ids, context=None):
409 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, ...)'))
411 if db in self._uid_cache:
413 if id in self._uid_cache[db]:
414 del self._uid_cache[db][id]
415 return super(users, self).unlink(cr, uid, ids, context=context)
417 def name_search(self, cr, user, name='', args=None, operator='ilike', context=None, limit=100):
424 ids = self.search(cr, user, [('login','=',name)]+ args, limit=limit)
426 ids = self.search(cr, user, [('name',operator,name)]+ args, limit=limit)
427 return self.name_get(cr, user, ids)
429 def copy(self, cr, uid, id, default=None, context=None):
430 user2copy = self.read(cr, uid, [id], ['login','name'])[0]
433 copy_pattern = _("%s (copy)")
434 copydef = dict(login=(copy_pattern % user2copy['login']),
435 name=(copy_pattern % user2copy['name']),
437 copydef.update(default)
438 return super(users, self).copy(cr, uid, id, copydef, context)
440 def context_get(self, cr, uid, context=None):
441 user = self.browse(cr, uid, uid, context)
443 for k in self._columns.keys():
444 if k.startswith('context_'):
445 res = getattr(user,k) or False
446 if isinstance(res, browse_record):
448 result[k[8:]] = res or False
451 def action_get(self, cr, uid, context=None):
452 dataobj = self.pool.get('ir.model.data')
453 data_id = dataobj._get_id(cr, 1, 'base', 'action_res_users_my')
454 return dataobj.browse(cr, uid, data_id, context=context).res_id
456 def authenticate(self, db, login, password, user_agent_env):
457 """Verifies and returns the user ID corresponding to the given
458 ``login`` and ``password`` combination, or False if there was
461 :param str db: the database on which user is trying to authenticate
462 :param str login: username
463 :param str password: user password
464 :param dict user_agent_env: environment dictionary describing any
465 relevant environment attributes
467 uid = self.login(db, login, password)
468 if uid == openerp.SUPERUSER_ID:
469 # Successfully logged in as admin!
470 # Attempt to guess the web base url...
471 if user_agent_env and user_agent_env.get('base_location'):
472 cr = pooler.get_db(db).cursor()
474 self.pool.get('ir.config_parameter').set_param(cr, uid, 'web.base.url',
475 user_agent_env['base_location'])
478 _logger.exception("Failed to update web.base.url configuration parameter")
483 def login(self, db, login, password):
486 cr = pooler.get_db(db).cursor()
488 # autocommit: our single request will be performed atomically.
489 # (In this way, there is no opportunity to have two transactions
490 # interleaving their cr.execute()..cr.commit() calls and have one
491 # of them rolled back due to a concurrent access.)
492 # We effectively unconditionally write the res_users line.
494 # Even w/ autocommit there's a chance the user row will be locked,
495 # in which case we can't delay the login just for the purpose of
496 # update the last login date - hence we use FOR UPDATE NOWAIT to
497 # try to get the lock - fail-fast
498 cr.execute("""SELECT id from res_users
499 WHERE login=%s AND password=%s
500 AND active FOR UPDATE NOWAIT""",
501 (tools.ustr(login), tools.ustr(password)))
502 cr.execute("""UPDATE res_users
503 SET date = now() AT TIME ZONE 'UTC'
504 WHERE login=%s AND password=%s AND active
506 (tools.ustr(login), tools.ustr(password)))
508 # Failing to acquire the lock on the res_users row probably means
509 # another request is holding it. No big deal, we don't want to
510 # prevent/delay login in that case. It will also have been logged
511 # as a SQL error, if anyone cares.
512 cr.execute("""SELECT id from res_users
513 WHERE login=%s AND password=%s
515 (tools.ustr(login), tools.ustr(password)))
523 def check_super(self, passwd):
524 if passwd == tools.config['admin_passwd']:
527 raise openerp.exceptions.AccessDenied()
529 def check(self, db, uid, passwd):
530 """Verifies that the given (uid, password) pair is authorized for the database ``db`` and
531 raise an exception if it is not."""
533 # empty passwords disallowed for obvious security reasons
534 raise openerp.exceptions.AccessDenied()
535 if self._uid_cache.get(db, {}).get(uid) == passwd:
537 cr = pooler.get_db(db).cursor()
539 cr.execute('SELECT COUNT(1) FROM res_users WHERE id=%s AND password=%s AND active=%s',
540 (int(uid), passwd, True))
541 res = cr.fetchone()[0]
543 raise openerp.exceptions.AccessDenied()
544 if self._uid_cache.has_key(db):
545 ulist = self._uid_cache[db]
548 self._uid_cache[db] = {uid:passwd}
552 def access(self, db, uid, passwd, sec_level, ids):
555 cr = pooler.get_db(db).cursor()
557 cr.execute('SELECT id FROM res_users WHERE id=%s AND password=%s', (uid, passwd))
560 raise openerp.exceptions.AccessDenied()
565 def change_password(self, cr, uid, old_passwd, new_passwd, context=None):
566 """Change current user password. Old password must be provided explicitly
567 to prevent hijacking an existing user session, or for cases where the cleartext
568 password is not used to authenticate requests.
571 :raise: openerp.exceptions.AccessDenied when old password is wrong
572 :raise: except_osv when new password is not set or empty
574 self.check(cr.dbname, uid, old_passwd)
576 return self.write(cr, uid, uid, {'password': new_passwd})
577 raise osv.except_osv(_('Warning!'), _("Setting empty passwords is not allowed for security reasons!"))
584 # Extension of res.groups and res.users with a relation for "implied" or
585 # "inherited" groups. Once a user belongs to a group, it automatically belongs
586 # to the implied groups (transitively).
590 """ A cset (constrained set) is a set of elements that may be constrained to
591 be a subset of other csets. Elements added to a cset are automatically
592 added to its supersets. Cycles in the subset constraints are supported.
594 def __init__(self, xs):
595 self.supersets = set()
596 self.elements = set(xs)
597 def subsetof(self, other):
598 if other is not self:
599 self.supersets.add(other)
600 other.update(self.elements)
601 def update(self, xs):
602 xs = set(xs) - self.elements
603 if xs: # xs will eventually be empty in case of a cycle
604 self.elements.update(xs)
605 for s in self.supersets:
608 return iter(self.elements)
611 """ return the concatenation of a list of iterables """
613 for l in ls: res.extend(l)
618 class groups_implied(osv.osv):
619 _inherit = 'res.groups'
621 def _get_trans_implied(self, cr, uid, ids, field, arg, context=None):
622 "computes the transitive closure of relation implied_ids"
623 memo = {} # use a memo for performance and cycle avoidance
626 memo[g] = cset(g.implied_ids)
627 for h in g.implied_ids:
628 computed_set(h).subsetof(memo[g])
632 for g in self.browse(cr, 1, ids, context):
633 res[g.id] = map(int, computed_set(g))
637 'implied_ids': fields.many2many('res.groups', 'res_groups_implied_rel', 'gid', 'hid',
638 string='Inherits', help='Users of this group automatically inherit those groups'),
639 'trans_implied_ids': fields.function(_get_trans_implied,
640 type='many2many', relation='res.groups', string='Transitively inherits'),
643 def create(self, cr, uid, values, context=None):
644 users = values.pop('users', None)
645 gid = super(groups_implied, self).create(cr, uid, values, context)
647 # delegate addition of users to add implied groups
648 self.write(cr, uid, [gid], {'users': users}, context)
651 def write(self, cr, uid, ids, values, context=None):
652 res = super(groups_implied, self).write(cr, uid, ids, values, context)
653 if values.get('users') or values.get('implied_ids'):
654 # add all implied groups (to all users of each group)
655 for g in self.browse(cr, uid, ids):
656 gids = map(int, g.trans_implied_ids)
657 vals = {'users': [(4, u.id) for u in g.users]}
658 super(groups_implied, self).write(cr, uid, gids, vals, context)
663 class users_implied(osv.osv):
664 _inherit = 'res.users'
666 def create(self, cr, uid, values, context=None):
667 groups = values.pop('groups_id', None)
668 user_id = super(users_implied, self).create(cr, uid, values, context)
670 # delegate addition of groups to add implied groups
671 self.write(cr, uid, [user_id], {'groups_id': groups}, context)
674 def write(self, cr, uid, ids, values, context=None):
675 if not isinstance(ids,list):
677 res = super(users_implied, self).write(cr, uid, ids, values, context)
678 if values.get('groups_id'):
679 # add implied groups for all users
680 for user in self.browse(cr, uid, ids):
681 gs = set(concat([g.trans_implied_ids for g in user.groups_id]))
682 vals = {'groups_id': [(4, g.id) for g in gs]}
683 super(users_implied, self).write(cr, uid, [user.id], vals, context)
691 # Extension of res.groups and res.users for the special groups view in the users
692 # form. This extension presents groups with selection and boolean widgets:
693 # - Groups are shown by application, with boolean and/or selection fields.
694 # Selection fields typically defines a role "Name" for the given application.
695 # - Uncategorized groups are presented as boolean fields and grouped in a
698 # The user form view is modified by an inherited view (base.user_groups_view);
699 # the inherited view replaces the field 'groups_id' by a set of reified group
700 # fields (boolean or selection fields). The arch of that view is regenerated
701 # each time groups are changed.
703 # Naming conventions for reified groups fields:
704 # - boolean field 'in_group_ID' is True iff
705 # ID is in 'groups_id'
706 # - boolean field 'in_groups_ID1_..._IDk' is True iff
707 # any of ID1, ..., IDk is in 'groups_id'
708 # - selection field 'sel_groups_ID1_..._IDk' is ID iff
709 # ID is in 'groups_id' and ID is maximal in the set {ID1, ..., IDk}
711 def name_boolean_group(id): return 'in_group_' + str(id)
712 def name_boolean_groups(ids): return 'in_groups_' + '_'.join(map(str, ids))
713 def name_selection_groups(ids): return 'sel_groups_' + '_'.join(map(str, ids))
715 def is_boolean_group(name): return name.startswith('in_group_')
716 def is_boolean_groups(name): return name.startswith('in_groups_')
717 def is_selection_groups(name): return name.startswith('sel_groups_')
718 def is_reified_group(name):
719 return is_boolean_group(name) or is_boolean_groups(name) or is_selection_groups(name)
721 def get_boolean_group(name): return int(name[9:])
722 def get_boolean_groups(name): return map(int, name[10:].split('_'))
723 def get_selection_groups(name): return map(int, name[11:].split('_'))
725 def partition(f, xs):
726 "return a pair equivalent to (filter(f, xs), filter(lambda x: not f(x), xs))"
729 (yes if f(x) else nos).append(x)
734 class groups_view(osv.osv):
735 _inherit = 'res.groups'
737 def create(self, cr, uid, values, context=None):
738 res = super(groups_view, self).create(cr, uid, values, context)
739 self.update_user_groups_view(cr, uid, context)
742 def write(self, cr, uid, ids, values, context=None):
743 res = super(groups_view, self).write(cr, uid, ids, values, context)
744 self.update_user_groups_view(cr, uid, context)
747 def unlink(self, cr, uid, ids, context=None):
748 res = super(groups_view, self).unlink(cr, uid, ids, context)
749 self.update_user_groups_view(cr, uid, context)
752 def update_user_groups_view(self, cr, uid, context=None):
753 # the view with id 'base.user_groups_view' inherits the user form view,
754 # and introduces the reified group fields
755 view = self.get_user_groups_view(cr, uid, context)
758 xml1.append(E.separator(string=_('Application'), colspan="4"))
759 for app, kind, gs in self.get_groups_by_application(cr, uid, context):
760 # hide groups in category 'Hidden' (except to group_no_one)
761 attrs = {'groups': 'base.group_no_one'} if app and app.xml_id == 'base.module_category_hidden' else {}
762 if kind == 'selection':
763 # application name with a selection field
764 field_name = name_selection_groups(map(int, gs))
765 xml1.append(E.field(name=field_name, **attrs))
766 xml1.append(E.newline())
768 # application separator with boolean fields
769 app_name = app and app.name or _('Other')
770 xml2.append(E.separator(string=app_name, colspan="4", **attrs))
772 field_name = name_boolean_group(g.id)
773 xml2.append(E.field(name=field_name, **attrs))
775 xml = E.field(*(xml1 + xml2), name="groups_id", position="replace")
776 xml.addprevious(etree.Comment("GENERATED AUTOMATICALLY BY GROUPS"))
777 xml_content = etree.tostring(xml, pretty_print=True, xml_declaration=True, encoding="utf-8")
778 view.write({'arch': xml_content})
781 def get_user_groups_view(self, cr, uid, context=None):
783 view = self.pool.get('ir.model.data').get_object(cr, 1, 'base', 'user_groups_view', context)
784 assert view and view._table_name == 'ir.ui.view'
789 def get_application_groups(self, cr, uid, domain=None, context=None):
790 return self.search(cr, uid, domain or [])
792 def get_groups_by_application(self, cr, uid, context=None):
793 """ return all groups classified by application (module category), as a list of pairs:
794 [(app, kind, [group, ...]), ...],
795 where app and group are browse records, and kind is either 'boolean' or 'selection'.
796 Applications are given in sequence order. If kind is 'selection', the groups are
797 given in reverse implication order.
801 # determine sequence order: a group should appear after its implied groups
802 order = dict.fromkeys(gs, 0)
804 for h in gs.intersection(g.trans_implied_ids):
806 # check whether order is total, i.e., sequence orders are distinct
807 if len(set(order.itervalues())) == len(gs):
808 return sorted(gs, key=lambda g: order[g])
811 # classify all groups by application
812 gids = self.get_application_groups(cr, uid, context=context)
813 by_app, others = {}, []
814 for g in self.browse(cr, uid, gids, context):
816 by_app.setdefault(g.category_id, []).append(g)
821 apps = sorted(by_app.iterkeys(), key=lambda a: a.sequence or 0)
823 gs = linearized(by_app[app])
825 res.append((app, 'selection', gs))
827 res.append((app, 'boolean', by_app[app]))
829 res.append((False, 'boolean', others))
834 class users_view(osv.osv):
835 _inherit = 'res.users'
837 def create(self, cr, uid, values, context=None):
838 self._set_reified_groups(values)
839 return super(users_view, self).create(cr, uid, values, context)
841 def write(self, cr, uid, ids, values, context=None):
842 self._set_reified_groups(values)
843 return super(users_view, self).write(cr, uid, ids, values, context)
845 def _set_reified_groups(self, values):
846 """ reflect reified group fields in values['groups_id'] """
847 if 'groups_id' in values:
848 # groups are already given, ignore group fields
849 for f in filter(is_reified_group, values.iterkeys()):
854 for f in values.keys():
855 if is_boolean_group(f):
856 target = add if values.pop(f) else remove
857 target.append(get_boolean_group(f))
858 elif is_boolean_groups(f):
859 if not values.pop(f):
860 remove.extend(get_boolean_groups(f))
861 elif is_selection_groups(f):
862 remove.extend(get_selection_groups(f))
863 selected = values.pop(f)
866 # update values *only* if groups are being modified, otherwise
867 # we introduce spurious changes that might break the super.write() call.
869 # remove groups in 'remove' and add groups in 'add'
870 values['groups_id'] = [(3, id) for id in remove] + [(4, id) for id in add]
872 def default_get(self, cr, uid, fields, context=None):
873 group_fields, fields = partition(is_reified_group, fields)
874 fields1 = (fields + ['groups_id']) if group_fields else fields
875 values = super(users_view, self).default_get(cr, uid, fields1, context)
876 self._get_reified_groups(group_fields, values)
879 def read(self, cr, uid, ids, fields=None, context=None, load='_classic_read'):
881 fields = self.fields_get(cr, uid, context=context).keys()
882 group_fields, fields = partition(is_reified_group, fields)
883 if not 'groups_id' in fields:
884 fields.append('groups_id')
885 res = super(users_view, self).read(cr, uid, ids, fields, context=context, load=load)
886 for values in (res if isinstance(res, list) else [res]):
887 self._get_reified_groups(group_fields, values)
890 def _get_reified_groups(self, fields, values):
891 """ compute the given reified group fields from values['groups_id'] """
892 gids = set(values.get('groups_id') or [])
894 if is_boolean_group(f):
895 values[f] = get_boolean_group(f) in gids
896 elif is_boolean_groups(f):
897 values[f] = not gids.isdisjoint(get_boolean_groups(f))
898 elif is_selection_groups(f):
899 selected = [gid for gid in get_selection_groups(f) if gid in gids]
900 values[f] = selected and selected[-1] or False
902 def fields_get(self, cr, uid, allfields=None, context=None, write_access=True):
903 res = super(users_view, self).fields_get(cr, uid, allfields, context, write_access)
904 # add reified groups fields
905 for app, kind, gs in self.pool.get('res.groups').get_groups_by_application(cr, uid, context):
906 if kind == 'selection':
907 # selection group field
908 tips = ['%s: %s' % (g.name, g.comment or '') for g in gs]
909 res[name_selection_groups(map(int, gs))] = {
911 'string': app and app.name or _('Other'),
912 'selection': [(False, '')] + [(g.id, g.name) for g in gs],
913 'help': '\n'.join(tips),
916 # boolean group fields
918 res[name_boolean_group(g.id)] = {
927 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: