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 _
44 _logger = logging.getLogger(__name__)
46 class groups(osv.osv):
48 _description = "Access Groups"
49 _rec_name = 'full_name'
51 def _get_full_name(self, cr, uid, ids, field, arg, context=None):
53 for g in self.browse(cr, uid, ids, context):
55 res[g.id] = '%s / %s' % (g.category_id.name, g.name)
61 'name': fields.char('Name', size=64, required=True, translate=True),
62 'users': fields.many2many('res.users', 'res_groups_users_rel', 'gid', 'uid', 'Users', ondelete='CASCADE'),
63 'model_access': fields.one2many('ir.model.access', 'group_id', 'Access Controls'),
64 'rule_groups': fields.many2many('ir.rule', 'rule_group_rel',
65 'group_id', 'rule_group_id', 'Rules', domain=[('global', '=', False)]),
66 'menu_access': fields.many2many('ir.ui.menu', 'ir_ui_menu_group_rel', 'gid', 'menu_id', 'Access Menu'),
67 'comment' : fields.text('Comment', size=250, translate=True),
68 'category_id': fields.many2one('ir.module.category', 'Application', select=True),
69 'full_name': fields.function(_get_full_name, type='char', string='Group Name'),
73 ('name_uniq', 'unique (category_id, name)', 'The name of the group must be unique !')
76 def search(self, cr, uid, args, offset=0, limit=None, order=None, context=None, count=False):
77 # add explicit ordering if search is sorted on full_name
78 if order and order.startswith('full_name'):
79 ids = super(groups, self).search(cr, uid, args, context=context)
80 gs = self.browse(cr, uid, ids, context)
81 gs.sort(key=lambda g: g.full_name, reverse=order.endswith('DESC'))
82 gs = gs[offset:offset+limit] if limit else gs[offset:]
84 return super(groups, self).search(cr, uid, args, offset, limit, order, context, count)
86 def copy(self, cr, uid, id, default=None, context=None):
87 group_name = self.read(cr, uid, [id], ['name'])[0]['name']
88 default.update({'name': _('%s (copy)')%group_name})
89 return super(groups, self).copy(cr, uid, id, default, context)
91 def write(self, cr, uid, ids, vals, context=None):
93 if vals['name'].startswith('-'):
94 raise osv.except_osv(_('Error'),
95 _('The name of the group can not start with "-"'))
96 res = super(groups, self).write(cr, uid, ids, vals, context=context)
97 self.pool.get('ir.model.access').call_cache_clearing_methods(cr)
100 def create(self, cr, uid, vals, context=None):
102 if vals['name'].startswith('-'):
103 raise osv.except_osv(_('Error'),
104 _('The name of the group can not start with "-"'))
105 gid = super(groups, self).create(cr, uid, vals, context=context)
106 if context and context.get('noadmin', False):
109 # assign this new group to user_root
110 user_obj = self.pool.get('res.users')
111 aid = user_obj.browse(cr, 1, user_obj._get_admin_id(cr))
113 aid.write({'groups_id': [(4, gid)]})
116 def get_extended_interface_group(self, cr, uid, context=None):
117 data_obj = self.pool.get('ir.model.data')
118 extended_group_data_id = data_obj._get_id(cr, uid, 'base', 'group_extended')
119 return data_obj.browse(cr, uid, extended_group_data_id, context=context).res_id
123 def _lang_get(self, cr, uid, context=None):
124 obj = self.pool.get('res.lang')
125 ids = obj.search(cr, uid, [('translatable','=',True)])
126 res = obj.read(cr, uid, ids, ['code', 'name'], context=context)
127 res = [(r['code'], r['name']) for r in res]
130 def _tz_get(self,cr,uid, context=None):
131 return [(x, x) for x in pytz.all_timezones]
133 class users(osv.osv):
139 WELCOME_MAIL_SUBJECT = u"Welcome to OpenERP"
140 WELCOME_MAIL_BODY = u"An OpenERP account has been created for you, "\
141 "\"%(name)s\".\n\nYour login is %(login)s, "\
142 "you should ask your supervisor or system administrator if you "\
143 "haven't been given your password yet.\n\n"\
144 "If you aren't %(name)s, this email reached you errorneously, "\
147 def get_welcome_mail_subject(self, cr, uid, context=None):
148 """ Returns the subject of the mail new users receive (when
149 created via the res.config.users wizard), default implementation
150 is to return config_users.WELCOME_MAIL_SUBJECT
152 return self.WELCOME_MAIL_SUBJECT
153 def get_welcome_mail_body(self, cr, uid, context=None):
154 """ Returns the subject of the mail new users receive (when
155 created via the res.config.users wizard), default implementation
156 is to return config_users.WELCOME_MAIL_BODY
158 return self.WELCOME_MAIL_BODY
160 def get_current_company(self, cr, uid):
161 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)
164 def send_welcome_email(self, cr, uid, id, context=None):
165 if isinstance(id,list): id = id[0]
166 user = self.read(cr, uid, id, ['email','login','name', 'user_email'], context=context)
167 email = user['email'] or user['user_email']
169 ir_mail_server = self.pool.get('ir.mail_server')
170 msg = ir_mail_server.build_email(email_from=None, # take config default
172 subject=self.get_welcome_mail_subject(cr, uid, context=context),
173 body=(self.get_welcome_mail_body(cr, uid, context=context) % user))
174 return ir_mail_server.send_email(cr, uid, msg, context=context)
176 def _set_interface_type(self, cr, uid, ids, name, value, arg, context=None):
177 """Implementation of 'view' function field setter, sets the type of interface of the users.
178 @param name: Name of the field
179 @param arg: User defined argument
180 @param value: new value returned
183 if not value or value not in ['simple','extended']:
185 group_obj = self.pool.get('res.groups')
186 extended_group_id = group_obj.get_extended_interface_group(cr, uid, context=context)
187 # First always remove the users from the group (avoids duplication if called twice)
188 self.write(cr, uid, ids, {'groups_id': [(3, extended_group_id)]}, context=context)
189 # Then add them back if requested
190 if value == 'extended':
191 self.write(cr, uid, ids, {'groups_id': [(4, extended_group_id)]}, context=context)
194 def _get_interface_type(self, cr, uid, ids, name, args, context=None):
195 """Implementation of 'view' function field getter, returns the type of interface of the users.
196 @param field_name: Name of the field
197 @param arg: User defined argument
198 @return: Dictionary of values
200 group_obj = self.pool.get('res.groups')
201 extended_group_id = group_obj.get_extended_interface_group(cr, uid, context=context)
202 extended_users = group_obj.read(cr, uid, extended_group_id, ['users'], context=context)['users']
203 return dict(zip(ids, ['extended' if user in extended_users else 'simple' for user in ids]))
205 def onchange_avatar(self, cr, uid, ids, value, context=None):
207 return {'value': {'avatar_big': value, 'avatar': value} }
208 return {'value': {'avatar_big': self._avatar_resize(cr, uid, value, 540, 450, context=context), 'avatar': self._avatar_resize(cr, uid, value, context=context)} }
210 def _set_avatar(self, cr, uid, id, name, value, args, context=None):
212 vals = {'avatar_big': value}
214 vals = {'avatar_big': self._avatar_resize(cr, uid, value, 540, 450, context=context)}
215 return self.write(cr, uid, [id], vals, context=context)
217 def _avatar_resize(self, cr, uid, avatar, height=180, width=150, context=None):
218 image_stream = io.BytesIO(avatar.decode('base64'))
219 img = Image.open(image_stream)
220 img.thumbnail((height, width), Image.ANTIALIAS)
221 img_stream = StringIO.StringIO()
222 img.save(img_stream, "PNG")
223 return img_stream.getvalue().encode('base64')
225 def _get_avatar(self, cr, uid, ids, name, args, context=None):
226 result = dict.fromkeys(ids, False)
227 for user in self.browse(cr, uid, ids, context=context):
229 result[user.id] = self._avatar_resize(cr, uid, user.avatar_big, context=context)
232 def _set_new_password(self, cr, uid, id, name, value, args, context=None):
234 # Do not update the password if no value is provided, ignore silently.
235 # For example web client submits False values for all empty fields.
238 # To change their own password users must use the client-specific change password wizard,
239 # so that the new password is immediately used for further RPC requests, otherwise the user
240 # will face unexpected 'Access Denied' exceptions.
241 raise osv.except_osv(_('Operation Canceled'), _('Please use the change password wizard (in User Preferences or User menu) to change your own password.'))
242 self.write(cr, uid, id, {'password': value})
244 def _get_password(self, cr, uid, ids, arg, karg, context=None):
245 return dict.fromkeys(ids, '')
248 'id': fields.integer('ID'),
249 'name': fields.char('User Name', size=64, required=True, select=True,
250 help="The new user's real name, used for searching"
251 " and most listings"),
252 'login': fields.char('Login', size=64, required=True,
253 help="Used to log into the system"),
254 '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."),
255 'new_password': fields.function(_get_password, type='char', size=64,
256 fnct_inv=_set_new_password,
257 string='Set password', help="Specify a value only when creating a user or if you're changing the user's password, "
258 "otherwise leave empty. After a change of password, the user has to login again."),
259 'user_email': fields.char('Email', size=64),
260 'signature': fields.text('Signature', size=64),
261 '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."),
262 'avatar': fields.function(_get_avatar, fnct_inv=_set_avatar, string='Avatar', type="binary",
264 'res.users': (lambda self, cr, uid, ids, c={}: ids, ['avatar_big'], 10),
265 }, 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."),
266 'active': fields.boolean('Active'),
267 '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."),
268 'menu_id': fields.many2one('ir.actions.actions', 'Menu Action', help="If specified, the action will replace the standard menu for this user."),
269 'groups_id': fields.many2many('res.groups', 'res_groups_users_rel', 'uid', 'gid', 'Groups'),
271 # Special behavior for this field: res.company.search() will only return the companies
272 # available to the current user (should be the user's companies?), when the user_preference
274 'company_id': fields.many2one('res.company', 'Company', required=True,
275 help="The company this user is currently working for.", context={'user_preference': True}),
277 'company_ids':fields.many2many('res.company','res_company_users_rel','user_id','cid','Companies'),
278 'context_lang': fields.selection(_lang_get, 'Language', required=True,
279 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."),
280 'context_tz': fields.selection(_tz_get, 'Timezone', size=64,
281 help="The user's timezone, used to output proper date and time values inside printed reports. "
282 "It is important to set a value for this field. You should use the same timezone "
283 "that is otherwise used to pick and render date and time values: your computer's timezone."),
284 'view': fields.function(_get_interface_type, type='selection', fnct_inv=_set_interface_type,
285 selection=[('simple','Simplified'),('extended','Extended')],
286 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."),
287 'menu_tips': fields.boolean('Menu Tips', help="Check out this box if you want to always display tips on each menu action"),
288 'date': fields.datetime('Latest Connection', readonly=True),
291 def on_change_company_id(self, cr, uid, ids, company_id):
294 'title': _("Company Switch Warning"),
295 '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)"),
299 def read(self,cr, uid, ids, fields=None, context=None, load='_classic_read'):
300 def override_password(o):
301 if 'password' in o and ( 'id' not in o or o['id'] != uid ):
302 o['password'] = '********'
304 result = super(users, self).read(cr, uid, ids, fields, context, load)
305 canwrite = self.pool.get('ir.model.access').check(cr, uid, 'res.users', 'write', False)
307 if isinstance(ids, (int, float)):
308 result = override_password(result)
310 result = map(override_password, result)
314 def _check_company(self, cr, uid, ids, context=None):
315 return all(((this.company_id in this.company_ids) or not this.company_ids) for this in self.browse(cr, uid, ids, context))
318 (_check_company, 'The chosen company is not in the allowed companies for this user', ['company_id', 'company_ids']),
322 ('login_key', 'UNIQUE (login)', 'You can not have two users with the same login !')
325 def _get_email_from(self, cr, uid, ids, context=None):
326 if not isinstance(ids, list):
328 res = dict.fromkeys(ids, False)
329 for user in self.browse(cr, uid, ids, context=context):
331 res[user.id] = "%s <%s>" % (user.name, user.user_email)
334 def _get_admin_id(self, cr):
335 if self.__admin_ids.get(cr.dbname) is None:
336 ir_model_data_obj = self.pool.get('ir.model.data')
337 mdid = ir_model_data_obj._get_id(cr, 1, 'base', 'user_root')
338 self.__admin_ids[cr.dbname] = ir_model_data_obj.read(cr, 1, [mdid], ['res_id'])[0]['res_id']
339 return self.__admin_ids[cr.dbname]
341 def _get_company(self,cr, uid, context=None, uid2=False):
344 user = self.pool.get('res.users').read(cr, uid, uid2, ['company_id'], context)
345 company_id = user.get('company_id', False)
346 return company_id and company_id[0] or False
348 def _get_companies(self, cr, uid, context=None):
349 c = self._get_company(cr, uid, context)
354 def _get_menu(self,cr, uid, context=None):
355 dataobj = self.pool.get('ir.model.data')
357 model, res_id = dataobj.get_object_reference(cr, uid, 'base', 'action_menu_admin')
358 if model != 'ir.actions.act_window':
364 def _get_group(self,cr, uid, context=None):
365 dataobj = self.pool.get('ir.model.data')
368 dummy,group_id = dataobj.get_object_reference(cr, 1, 'base', 'group_user')
369 result.append(group_id)
370 dummy,group_id = dataobj.get_object_reference(cr, 1, 'base', 'group_partner_manager')
371 result.append(group_id)
373 # If these groups does not exists anymore
377 def _get_avatar(self, cr, uid, context=None):
378 # default avatar file name: avatar0 -> avatar6.png, choose randomly
379 avatar_path = openerp.modules.get_module_resource('base', 'static/src/img', 'avatar%d.png' % random.randint(0, 6))
380 return self._avatar_resize(cr, uid, open(avatar_path, 'rb').read().encode('base64'), context=context)
384 'context_lang': 'en_US',
385 'avatar': _get_avatar,
387 'menu_id': _get_menu,
388 'company_id': _get_company,
389 'company_ids': _get_companies,
390 'groups_id': _get_group,
394 # User can write to a few of her own fields (but not her groups for example)
395 SELF_WRITEABLE_FIELDS = ['menu_tips','view', 'password', 'signature', 'action_id', 'company_id', 'user_email', 'name']
397 def write(self, cr, uid, ids, values, context=None):
398 if not hasattr(ids, '__iter__'):
401 for key in values.keys():
402 if not (key in self.SELF_WRITEABLE_FIELDS or key.startswith('context_')):
405 if 'company_id' in values:
406 if not (values['company_id'] in self.read(cr, 1, uid, ['company_ids'], context=context)['company_ids']):
407 del values['company_id']
408 uid = 1 # safe fields only, so we write as super-user to bypass access rights
410 res = super(users, self).write(cr, uid, ids, values, context=context)
412 # clear caches linked to the users
413 self.pool.get('ir.model.access').call_cache_clearing_methods(cr)
414 clear = partial(self.pool.get('ir.rule').clear_cache, cr)
417 if db in self._uid_cache:
419 if id in self._uid_cache[db]:
420 del self._uid_cache[db][id]
424 def unlink(self, cr, uid, ids, context=None):
426 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, ...)'))
428 if db in self._uid_cache:
430 if id in self._uid_cache[db]:
431 del self._uid_cache[db][id]
432 return super(users, self).unlink(cr, uid, ids, context=context)
434 def name_search(self, cr, user, name='', args=None, operator='ilike', context=None, limit=100):
441 ids = self.search(cr, user, [('login','=',name)]+ args, limit=limit)
443 ids = self.search(cr, user, [('name',operator,name)]+ args, limit=limit)
444 return self.name_get(cr, user, ids)
446 def copy(self, cr, uid, id, default=None, context=None):
447 user2copy = self.read(cr, uid, [id], ['login','name'])[0]
450 copy_pattern = _("%s (copy)")
451 copydef = dict(login=(copy_pattern % user2copy['login']),
452 name=(copy_pattern % user2copy['name']),
454 copydef.update(default)
455 return super(users, self).copy(cr, uid, id, copydef, context)
457 def context_get(self, cr, uid, context=None):
458 user = self.browse(cr, uid, uid, context)
460 for k in self._columns.keys():
461 if k.startswith('context_'):
462 res = getattr(user,k) or False
463 if isinstance(res, browse_record):
465 result[k[8:]] = res or False
468 def action_get(self, cr, uid, context=None):
469 dataobj = self.pool.get('ir.model.data')
470 data_id = dataobj._get_id(cr, 1, 'base', 'action_res_users_my')
471 return dataobj.browse(cr, uid, data_id, context=context).res_id
473 def authenticate(self, db, login, password, user_agent_env):
474 """Verifies and returns the user ID corresponding to the given
475 ``login`` and ``password`` combination, or False if there was
478 :param str db: the database on which user is trying to authenticate
479 :param str login: username
480 :param str password: user password
481 :param dict user_agent_env: environment dictionary describing any
482 relevant environment attributes
484 uid = self.login(db, login, password)
485 if uid == openerp.SUPERUSER_ID:
486 # Successfully logged in as admin!
487 # Attempt to guess the web base url...
488 if user_agent_env and user_agent_env.get('base_location'):
489 cr = pooler.get_db(db).cursor()
491 self.pool.get('ir.config_parameter').set_param(cr, uid, 'web.base.url',
492 user_agent_env['base_location'])
495 _logger.exception("Failed to update web.base.url configuration parameter")
500 def login(self, db, login, password):
503 cr = pooler.get_db(db).cursor()
505 # autocommit: our single request will be performed atomically.
506 # (In this way, there is no opportunity to have two transactions
507 # interleaving their cr.execute()..cr.commit() calls and have one
508 # of them rolled back due to a concurrent access.)
509 # We effectively unconditionally write the res_users line.
511 # Even w/ autocommit there's a chance the user row will be locked,
512 # in which case we can't delay the login just for the purpose of
513 # update the last login date - hence we use FOR UPDATE NOWAIT to
514 # try to get the lock - fail-fast
515 cr.execute("""SELECT id from res_users
516 WHERE login=%s AND password=%s
517 AND active FOR UPDATE NOWAIT""",
518 (tools.ustr(login), tools.ustr(password)))
519 cr.execute("""UPDATE res_users
520 SET date = now() AT TIME ZONE 'UTC'
521 WHERE login=%s AND password=%s AND active
523 (tools.ustr(login), tools.ustr(password)))
525 # Failing to acquire the lock on the res_users row probably means
526 # another request is holding it. No big deal, we don't want to
527 # prevent/delay login in that case. It will also have been logged
528 # as a SQL error, if anyone cares.
529 cr.execute("""SELECT id from res_users
530 WHERE login=%s AND password=%s
532 (tools.ustr(login), tools.ustr(password)))
540 def check_super(self, passwd):
541 if passwd == tools.config['admin_passwd']:
544 raise openerp.exceptions.AccessDenied()
546 def check(self, db, uid, passwd):
547 """Verifies that the given (uid, password) pair is authorized for the database ``db`` and
548 raise an exception if it is not."""
550 # empty passwords disallowed for obvious security reasons
551 raise openerp.exceptions.AccessDenied()
552 if self._uid_cache.get(db, {}).get(uid) == passwd:
554 cr = pooler.get_db(db).cursor()
556 cr.execute('SELECT COUNT(1) FROM res_users WHERE id=%s AND password=%s AND active=%s',
557 (int(uid), passwd, True))
558 res = cr.fetchone()[0]
560 raise openerp.exceptions.AccessDenied()
561 if self._uid_cache.has_key(db):
562 ulist = self._uid_cache[db]
565 self._uid_cache[db] = {uid:passwd}
569 def access(self, db, uid, passwd, sec_level, ids):
572 cr = pooler.get_db(db).cursor()
574 cr.execute('SELECT id FROM res_users WHERE id=%s AND password=%s', (uid, passwd))
577 raise openerp.exceptions.AccessDenied()
582 def change_password(self, cr, uid, old_passwd, new_passwd, context=None):
583 """Change current user password. Old password must be provided explicitly
584 to prevent hijacking an existing user session, or for cases where the cleartext
585 password is not used to authenticate requests.
588 :raise: openerp.exceptions.AccessDenied when old password is wrong
589 :raise: except_osv when new password is not set or empty
591 self.check(cr.dbname, uid, old_passwd)
593 return self.write(cr, uid, uid, {'password': new_passwd})
594 raise osv.except_osv(_('Warning!'), _("Setting empty passwords is not allowed for security reasons!"))
601 # Extension of res.groups and res.users with a relation for "implied" or
602 # "inherited" groups. Once a user belongs to a group, it automatically belongs
603 # to the implied groups (transitively).
607 """ A cset (constrained set) is a set of elements that may be constrained to
608 be a subset of other csets. Elements added to a cset are automatically
609 added to its supersets. Cycles in the subset constraints are supported.
611 def __init__(self, xs):
612 self.supersets = set()
613 self.elements = set(xs)
614 def subsetof(self, other):
615 if other is not self:
616 self.supersets.add(other)
617 other.update(self.elements)
618 def update(self, xs):
619 xs = set(xs) - self.elements
620 if xs: # xs will eventually be empty in case of a cycle
621 self.elements.update(xs)
622 for s in self.supersets:
625 return iter(self.elements)
628 """ return the concatenation of a list of iterables """
630 for l in ls: res.extend(l)
635 class groups_implied(osv.osv):
636 _inherit = 'res.groups'
638 def _get_trans_implied(self, cr, uid, ids, field, arg, context=None):
639 "computes the transitive closure of relation implied_ids"
640 memo = {} # use a memo for performance and cycle avoidance
643 memo[g] = cset(g.implied_ids)
644 for h in g.implied_ids:
645 computed_set(h).subsetof(memo[g])
649 for g in self.browse(cr, 1, ids, context):
650 res[g.id] = map(int, computed_set(g))
654 'implied_ids': fields.many2many('res.groups', 'res_groups_implied_rel', 'gid', 'hid',
655 string='Inherits', help='Users of this group automatically inherit those groups'),
656 'trans_implied_ids': fields.function(_get_trans_implied,
657 type='many2many', relation='res.groups', string='Transitively inherits'),
660 def create(self, cr, uid, values, context=None):
661 users = values.pop('users', None)
662 gid = super(groups_implied, self).create(cr, uid, values, context)
664 # delegate addition of users to add implied groups
665 self.write(cr, uid, [gid], {'users': users}, context)
668 def write(self, cr, uid, ids, values, context=None):
669 res = super(groups_implied, self).write(cr, uid, ids, values, context)
670 if values.get('users') or values.get('implied_ids'):
671 # add all implied groups (to all users of each group)
672 for g in self.browse(cr, uid, ids):
673 gids = map(int, g.trans_implied_ids)
674 vals = {'users': [(4, u.id) for u in g.users]}
675 super(groups_implied, self).write(cr, uid, gids, vals, context)
680 class users_implied(osv.osv):
681 _inherit = 'res.users'
683 def create(self, cr, uid, values, context=None):
684 groups = values.pop('groups_id', None)
685 user_id = super(users_implied, self).create(cr, uid, values, context)
687 # delegate addition of groups to add implied groups
688 self.write(cr, uid, [user_id], {'groups_id': groups}, context)
691 def write(self, cr, uid, ids, values, context=None):
692 if not isinstance(ids,list):
694 res = super(users_implied, self).write(cr, uid, ids, values, context)
695 if values.get('groups_id'):
696 # add implied groups for all users
697 for user in self.browse(cr, uid, ids):
698 gs = set(concat([g.trans_implied_ids for g in user.groups_id]))
699 vals = {'groups_id': [(4, g.id) for g in gs]}
700 super(users_implied, self).write(cr, uid, [user.id], vals, context)
708 # Extension of res.groups and res.users for the special groups view in the users
709 # form. This extension presents groups with selection and boolean widgets:
710 # - Groups are shown by application, with boolean and/or selection fields.
711 # Selection fields typically defines a role "Name" for the given application.
712 # - Uncategorized groups are presented as boolean fields and grouped in a
715 # The user form view is modified by an inherited view (base.user_groups_view);
716 # the inherited view replaces the field 'groups_id' by a set of reified group
717 # fields (boolean or selection fields). The arch of that view is regenerated
718 # each time groups are changed.
720 # Naming conventions for reified groups fields:
721 # - boolean field 'in_group_ID' is True iff
722 # ID is in 'groups_id'
723 # - boolean field 'in_groups_ID1_..._IDk' is True iff
724 # any of ID1, ..., IDk is in 'groups_id'
725 # - selection field 'sel_groups_ID1_..._IDk' is ID iff
726 # ID is in 'groups_id' and ID is maximal in the set {ID1, ..., IDk}
728 def name_boolean_group(id): return 'in_group_' + str(id)
729 def name_boolean_groups(ids): return 'in_groups_' + '_'.join(map(str, ids))
730 def name_selection_groups(ids): return 'sel_groups_' + '_'.join(map(str, ids))
732 def is_boolean_group(name): return name.startswith('in_group_')
733 def is_boolean_groups(name): return name.startswith('in_groups_')
734 def is_selection_groups(name): return name.startswith('sel_groups_')
735 def is_reified_group(name):
736 return is_boolean_group(name) or is_boolean_groups(name) or is_selection_groups(name)
738 def get_boolean_group(name): return int(name[9:])
739 def get_boolean_groups(name): return map(int, name[10:].split('_'))
740 def get_selection_groups(name): return map(int, name[11:].split('_'))
742 def partition(f, xs):
743 "return a pair equivalent to (filter(f, xs), filter(lambda x: not f(x), xs))"
746 (yes if f(x) else nos).append(x)
751 class groups_view(osv.osv):
752 _inherit = 'res.groups'
754 def create(self, cr, uid, values, context=None):
755 res = super(groups_view, self).create(cr, uid, values, context)
756 self.update_user_groups_view(cr, uid, context)
759 def write(self, cr, uid, ids, values, context=None):
760 res = super(groups_view, self).write(cr, uid, ids, values, context)
761 self.update_user_groups_view(cr, uid, context)
764 def unlink(self, cr, uid, ids, context=None):
765 res = super(groups_view, self).unlink(cr, uid, ids, context)
766 self.update_user_groups_view(cr, uid, context)
769 def update_user_groups_view(self, cr, uid, context=None):
770 # the view with id 'base.user_groups_view' inherits the user form view,
771 # and introduces the reified group fields
772 view = self.get_user_groups_view(cr, uid, context)
776 xml1.append(E.separator(string=_('Application'), colspan="4"))
777 for app, kind, gs in self.get_groups_by_application(cr, uid, context):
778 if kind == 'selection':
779 # application name with a selection field
780 field_name = name_selection_groups(map(int, gs))
781 xml1.append(E.field(name=field_name))
782 xml1.append(E.newline())
784 # application separator with boolean fields
785 app_name = app and app.name or _('Other')
786 xml2.append(E.separator(string=app_name, colspan="4"))
788 field_name = name_boolean_group(g.id)
789 xml2.append(E.field(name=field_name))
791 xml = E.field(*(xml1 + xml2), name="groups_id", position="replace")
792 xml.addprevious(etree.Comment("GENERATED AUTOMATICALLY BY GROUPS"))
793 xml_content = etree.tostring(xml, pretty_print=True, xml_declaration=True, encoding="utf-8")
794 view.write({'arch': xml_content})
797 def get_user_groups_view(self, cr, uid, context=None):
799 view = self.pool.get('ir.model.data').get_object(cr, 1, 'base', 'user_groups_view', context)
800 assert view and view._table_name == 'ir.ui.view'
805 def get_application_groups(self, cr, uid, domain=None, context=None):
806 return self.search(cr, uid, domain or [])
808 def get_groups_by_application(self, cr, uid, context=None):
809 """ return all groups classified by application (module category), as a list of pairs:
810 [(app, kind, [group, ...]), ...],
811 where app and group are browse records, and kind is either 'boolean' or 'selection'.
812 Applications are given in sequence order. If kind is 'selection', the groups are
813 given in reverse implication order.
817 # determine sequence order: a group should appear after its implied groups
818 order = dict.fromkeys(gs, 0)
820 for h in gs.intersection(g.trans_implied_ids):
822 # check whether order is total, i.e., sequence orders are distinct
823 if len(set(order.itervalues())) == len(gs):
824 return sorted(gs, key=lambda g: order[g])
827 # classify all groups by application
828 gids = self.get_application_groups(cr, uid, context=context)
829 by_app, others = {}, []
830 for g in self.browse(cr, uid, gids, context):
832 by_app.setdefault(g.category_id, []).append(g)
837 apps = sorted(by_app.iterkeys(), key=lambda a: a.sequence or 0)
839 gs = linearized(by_app[app])
841 res.append((app, 'selection', gs))
843 res.append((app, 'boolean', by_app[app]))
845 res.append((False, 'boolean', others))
850 class users_view(osv.osv):
851 _inherit = 'res.users'
853 def create(self, cr, uid, values, context=None):
854 self._set_reified_groups(values)
855 return super(users_view, self).create(cr, uid, values, context)
857 def write(self, cr, uid, ids, values, context=None):
858 self._set_reified_groups(values)
859 return super(users_view, self).write(cr, uid, ids, values, context)
861 def _set_reified_groups(self, values):
862 """ reflect reified group fields in values['groups_id'] """
863 if 'groups_id' in values:
864 # groups are already given, ignore group fields
865 for f in filter(is_reified_group, values.iterkeys()):
870 for f in values.keys():
871 if is_boolean_group(f):
872 target = add if values.pop(f) else remove
873 target.append(get_boolean_group(f))
874 elif is_boolean_groups(f):
875 if not values.pop(f):
876 remove.extend(get_boolean_groups(f))
877 elif is_selection_groups(f):
878 remove.extend(get_selection_groups(f))
879 selected = values.pop(f)
882 # update values *only* if groups are being modified, otherwise
883 # we introduce spurious changes that might break the super.write() call.
885 # remove groups in 'remove' and add groups in 'add'
886 values['groups_id'] = [(3, id) for id in remove] + [(4, id) for id in add]
888 def default_get(self, cr, uid, fields, context=None):
889 group_fields, fields = partition(is_reified_group, fields)
890 fields1 = (fields + ['groups_id']) if group_fields else fields
891 values = super(users_view, self).default_get(cr, uid, fields1, context)
892 self._get_reified_groups(group_fields, values)
895 def read(self, cr, uid, ids, fields=None, context=None, load='_classic_read'):
897 fields = self.fields_get(cr, uid, context=context).keys()
898 group_fields, fields = partition(is_reified_group, fields)
899 if not 'groups_id' in fields:
900 fields.append('groups_id')
901 res = super(users_view, self).read(cr, uid, ids, fields, context=context, load=load)
902 for values in (res if isinstance(res, list) else [res]):
903 self._get_reified_groups(group_fields, values)
906 def _get_reified_groups(self, fields, values):
907 """ compute the given reified group fields from values['groups_id'] """
908 gids = set(values.get('groups_id') or [])
910 if is_boolean_group(f):
911 values[f] = get_boolean_group(f) in gids
912 elif is_boolean_groups(f):
913 values[f] = not gids.isdisjoint(get_boolean_groups(f))
914 elif is_selection_groups(f):
915 selected = [gid for gid in get_selection_groups(f) if gid in gids]
916 values[f] = selected and selected[-1] or False
918 def fields_get(self, cr, uid, allfields=None, context=None, write_access=True):
919 res = super(users_view, self).fields_get(cr, uid, allfields, context, write_access)
920 # add reified groups fields
921 for app, kind, gs in self.pool.get('res.groups').get_groups_by_application(cr, uid, context):
922 if kind == 'selection':
923 # selection group field
924 tips = ['%s: %s' % (g.name, g.comment or '') for g in gs]
925 res[name_selection_groups(map(int, gs))] = {
927 'string': app and app.name or _('Other'),
928 'selection': [(False, '')] + [(g.id, g.name) for g in gs],
929 'help': '\n'.join(tips),
932 # boolean group fields
934 res[name_boolean_group(g.id)] = {
943 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: