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
28 from lxml import etree
29 from lxml.builder import E
32 import openerp.exceptions
33 from osv import fields,osv
34 from osv.orm import browse_record
37 from service import security
39 from tools.translate import _
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)
99 def _lang_get(self, cr, uid, context=None):
100 obj = self.pool.get('res.lang')
101 ids = obj.search(cr, uid, [('translatable','=',True)])
102 res = obj.read(cr, uid, ids, ['code', 'name'], context=context)
103 res = [(r['code'], r['name']) for r in res]
106 def _tz_get(self,cr,uid, context=None):
107 return [(x, x) for x in pytz.all_timezones]
109 class users(osv.osv):
113 _description = 'Users'
116 WELCOME_MAIL_SUBJECT = u"Welcome to OpenERP"
117 WELCOME_MAIL_BODY = u"An OpenERP account has been created for you, "\
118 "\"%(name)s\".\n\nYour login is %(login)s, "\
119 "you should ask your supervisor or system administrator if you "\
120 "haven't been given your password yet.\n\n"\
121 "If you aren't %(name)s, this email reached you errorneously, "\
124 def get_welcome_mail_subject(self, cr, uid, context=None):
125 """ Returns the subject of the mail new users receive (when
126 created via the res.config.users wizard), default implementation
127 is to return config_users.WELCOME_MAIL_SUBJECT
129 return self.WELCOME_MAIL_SUBJECT
130 def get_welcome_mail_body(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_BODY
135 return self.WELCOME_MAIL_BODY
137 def get_current_company(self, cr, uid):
138 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)
141 def send_welcome_email(self, cr, uid, id, context=None):
142 if isinstance(id,list): id = id[0]
143 user = self.read(cr, uid, id, ['email','login','name', 'user_email'], context=context)
144 email = user['email'] or user['user_email']
146 ir_mail_server = self.pool.get('ir.mail_server')
147 msg = ir_mail_server.build_email(email_from=None, # take config default
149 subject=self.get_welcome_mail_subject(cr, uid, context=context),
150 body=(self.get_welcome_mail_body(cr, uid, context=context) % user))
151 return ir_mail_server.send_email(cr, uid, msg, context=context)
153 def _set_new_password(self, cr, uid, id, name, value, args, context=None):
155 # Do not update the password if no value is provided, ignore silently.
156 # For example web client submits False values for all empty fields.
159 # To change their own password users must use the client-specific change password wizard,
160 # so that the new password is immediately used for further RPC requests, otherwise the user
161 # will face unexpected 'Access Denied' exceptions.
162 raise osv.except_osv(_('Operation Canceled'), _('Please use the change password wizard (in User Preferences or User menu) to change your own password.'))
163 self.write(cr, uid, id, {'password': value})
165 def _get_password(self, cr, uid, ids, arg, karg, context=None):
166 return dict.fromkeys(ids, '')
168 def _get_image(self, cr, uid, ids, name, args, context=None):
169 result = dict.fromkeys(ids, False)
170 for obj in self.browse(cr, uid, ids, context=context):
171 resized_image_dict = tools.get_resized_images(obj.image)
173 'image_medium': resized_image_dict['image_medium'],
174 'image_small': resized_image_dict['image_small'],
178 def _set_image(self, cr, uid, id, name, value, args, context=None):
179 return self.write(cr, uid, [id], {'image': tools.resize_image_big(value)}, context=context)
181 def onchange_image(self, cr, uid, ids, value, context=None):
182 return {'value': tools.get_resized_images(value)}
185 'id': fields.integer('ID'),
186 'name': fields.char('User Name', size=64, required=True, select=True,
187 help="The new user's real name, used for searching"
188 " and most listings"),
189 'login': fields.char('Login', size=64, required=True,
190 help="Used to log into the system"),
191 '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."),
192 'new_password': fields.function(_get_password, type='char', size=64,
193 fnct_inv=_set_new_password,
194 string='Set Password', help="Specify a value only when creating a user or if you're changing the user's password, "
195 "otherwise leave empty. After a change of password, the user has to login again."),
196 'user_email': fields.char('Email', size=64),
197 'signature': fields.text('Signature', size=64),
198 'image': fields.binary("Avatar",
199 help="This field holds the image used as avatar for the "\
200 "user. The image is base64 encoded, and PIL-supported. "\
201 "It is limited to a 1024x1024 px image."),
202 'image_medium': fields.function(_get_image, fnct_inv=_set_image,
203 string="Medium-sized avatar", type="binary", multi="_get_image",
205 'res.users': (lambda self, cr, uid, ids, c={}: ids, ['image'], 10),
207 help="Medium-sized image of the user. It is automatically "\
208 "resized as a 180x180 px image, with aspect ratio preserved. "\
209 "Use this field in form views or some kanban views."),
210 'image_small': fields.function(_get_image, fnct_inv=_set_image,
211 string="Smal-sized avatar", type="binary", multi="_get_image",
213 'res.users': (lambda self, cr, uid, ids, c={}: ids, ['image'], 10),
215 help="Small-sized image of the user. It is automatically "\
216 "resized as a 50x50 px image, with aspect ratio preserved. "\
217 "Use this field anywhere a small image is required."),
218 'active': fields.boolean('Active'),
219 '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."),
220 'menu_id': fields.many2one('ir.actions.actions', 'Menu Action', help="If specified, the action will replace the standard menu for this user."),
221 'groups_id': fields.many2many('res.groups', 'res_groups_users_rel', 'uid', 'gid', 'Groups'),
223 # Special behavior for this field: res.company.search() will only return the companies
224 # available to the current user (should be the user's companies?), when the user_preference
226 'company_id': fields.many2one('res.company', 'Company', required=True,
227 help="The company this user is currently working for.", context={'user_preference': True}),
229 'company_ids':fields.many2many('res.company','res_company_users_rel','user_id','cid','Companies'),
230 'context_lang': fields.selection(_lang_get, 'Language', required=True,
231 help="The default language used in the graphical user interface, when translations are available. To add a new language, you can use the 'Load a Translation' wizard available from the 'Administration' menu."),
232 'context_tz': fields.selection(_tz_get, 'Timezone', size=64,
233 help="The user's timezone, used to output proper date and time values inside printed reports. "
234 "It is important to set a value for this field. You should use the same timezone "
235 "that is otherwise used to pick and render date and time values: your computer's timezone."),
236 'date': fields.datetime('Latest Connection', readonly=True),
239 def on_change_company_id(self, cr, uid, ids, company_id):
242 'title': _("Company Switch Warning"),
243 '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)"),
247 def read(self,cr, uid, ids, fields=None, context=None, load='_classic_read'):
248 def override_password(o):
249 if 'password' in o and ( 'id' not in o or o['id'] != uid ):
250 o['password'] = '********'
252 result = super(users, self).read(cr, uid, ids, fields, context, load)
253 canwrite = self.pool.get('ir.model.access').check(cr, uid, 'res.users', 'write', False)
255 if isinstance(ids, (int, float)):
256 result = override_password(result)
258 result = map(override_password, result)
262 def _check_company(self, cr, uid, ids, context=None):
263 return all(((this.company_id in this.company_ids) or not this.company_ids) for this in self.browse(cr, uid, ids, context))
266 (_check_company, 'The chosen company is not in the allowed companies for this user', ['company_id', 'company_ids']),
270 ('login_key', 'UNIQUE (login)', 'You can not have two users with the same login !')
273 def _get_email_from(self, cr, uid, ids, context=None):
274 if not isinstance(ids, list):
276 res = dict.fromkeys(ids, False)
277 for user in self.browse(cr, uid, ids, context=context):
279 res[user.id] = "%s <%s>" % (user.name, user.user_email)
282 def _get_admin_id(self, cr):
283 if self.__admin_ids.get(cr.dbname) is None:
284 ir_model_data_obj = self.pool.get('ir.model.data')
285 mdid = ir_model_data_obj._get_id(cr, 1, 'base', 'user_root')
286 self.__admin_ids[cr.dbname] = ir_model_data_obj.read(cr, 1, [mdid], ['res_id'])[0]['res_id']
287 return self.__admin_ids[cr.dbname]
289 def _get_company(self,cr, uid, context=None, uid2=False):
292 user = self.pool.get('res.users').read(cr, uid, uid2, ['company_id'], context)
293 company_id = user.get('company_id', False)
294 return company_id and company_id[0] or False
296 def _get_companies(self, cr, uid, context=None):
297 c = self._get_company(cr, uid, context)
302 def _get_menu(self,cr, uid, context=None):
303 dataobj = self.pool.get('ir.model.data')
305 model, res_id = dataobj.get_object_reference(cr, uid, 'base', 'action_menu_admin')
306 if model != 'ir.actions.act_window':
312 def _get_group(self,cr, uid, context=None):
313 dataobj = self.pool.get('ir.model.data')
316 dummy,group_id = dataobj.get_object_reference(cr, 1, 'base', 'group_user')
317 result.append(group_id)
318 dummy,group_id = dataobj.get_object_reference(cr, 1, 'base', 'group_partner_manager')
319 result.append(group_id)
321 # If these groups does not exists anymore
325 def _get_default_image(self, cr, uid, context=None):
326 # default image file name: avatar0 -> avatar6.png, choose randomly
327 image_path = openerp.modules.get_module_resource('base', 'static/src/img', 'avatar%d.png' % random.randint(0, 6))
328 return tools.resize_image_big(open(image_path, 'rb').read().encode('base64'))
332 'context_lang': lambda self, cr, uid, context: context.get('lang', 'en_US'),
333 'context_tz': lambda self, cr, uid, context: context.get('tz', False),
334 'image': _get_default_image,
336 'menu_id': _get_menu,
337 'company_id': _get_company,
338 'company_ids': _get_companies,
339 'groups_id': _get_group,
342 # User can write to a few of her own fields (but not her groups for example)
343 SELF_WRITEABLE_FIELDS = ['password', 'signature', 'action_id', 'company_id', 'user_email', 'name', 'image', 'image_medium', 'image_small']
345 def write(self, cr, uid, ids, values, context=None):
346 if not hasattr(ids, '__iter__'):
349 for key in values.keys():
350 if not (key in self.SELF_WRITEABLE_FIELDS or key.startswith('context_')):
353 if 'company_id' in values:
354 if not (values['company_id'] in self.read(cr, 1, uid, ['company_ids'], context=context)['company_ids']):
355 del values['company_id']
356 uid = 1 # safe fields only, so we write as super-user to bypass access rights
358 res = super(users, self).write(cr, uid, ids, values, context=context)
360 # clear caches linked to the users
361 self.pool.get('ir.model.access').call_cache_clearing_methods(cr)
362 clear = partial(self.pool.get('ir.rule').clear_cache, cr)
365 if db in self._uid_cache:
367 if id in self._uid_cache[db]:
368 del self._uid_cache[db][id]
372 def unlink(self, cr, uid, ids, context=None):
374 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, ...)'))
376 if db in self._uid_cache:
378 if id in self._uid_cache[db]:
379 del self._uid_cache[db][id]
380 return super(users, self).unlink(cr, uid, ids, context=context)
382 def name_search(self, cr, user, name='', args=None, operator='ilike', context=None, limit=100):
389 ids = self.search(cr, user, [('login','=',name)]+ args, limit=limit)
391 ids = self.search(cr, user, [('name',operator,name)]+ args, limit=limit)
392 return self.name_get(cr, user, ids)
394 def copy(self, cr, uid, id, default=None, context=None):
395 user2copy = self.read(cr, uid, [id], ['login','name'])[0]
398 copy_pattern = _("%s (copy)")
399 copydef = dict(login=(copy_pattern % user2copy['login']),
400 name=(copy_pattern % user2copy['name']),
402 copydef.update(default)
403 return super(users, self).copy(cr, uid, id, copydef, context)
405 def context_get(self, cr, uid, context=None):
406 user = self.browse(cr, uid, uid, context)
408 for k in self._columns.keys():
409 if k.startswith('context_'):
410 res = getattr(user,k) or False
411 if isinstance(res, browse_record):
413 result[k[8:]] = res or False
416 def action_get(self, cr, uid, context=None):
417 dataobj = self.pool.get('ir.model.data')
418 data_id = dataobj._get_id(cr, 1, 'base', 'action_res_users_my')
419 return dataobj.browse(cr, uid, data_id, context=context).res_id
421 def authenticate(self, db, login, password, user_agent_env):
422 """Verifies and returns the user ID corresponding to the given
423 ``login`` and ``password`` combination, or False if there was
426 :param str db: the database on which user is trying to authenticate
427 :param str login: username
428 :param str password: user password
429 :param dict user_agent_env: environment dictionary describing any
430 relevant environment attributes
432 uid = self.login(db, login, password)
433 if uid == openerp.SUPERUSER_ID:
434 # Successfully logged in as admin!
435 # Attempt to guess the web base url...
436 if user_agent_env and user_agent_env.get('base_location'):
437 cr = pooler.get_db(db).cursor()
439 self.pool.get('ir.config_parameter').set_param(cr, uid, 'web.base.url',
440 user_agent_env['base_location'])
443 _logger.exception("Failed to update web.base.url configuration parameter")
448 def login(self, db, login, password):
451 cr = pooler.get_db(db).cursor()
453 # autocommit: our single request will be performed atomically.
454 # (In this way, there is no opportunity to have two transactions
455 # interleaving their cr.execute()..cr.commit() calls and have one
456 # of them rolled back due to a concurrent access.)
457 # We effectively unconditionally write the res_users line.
459 # Even w/ autocommit there's a chance the user row will be locked,
460 # in which case we can't delay the login just for the purpose of
461 # update the last login date - hence we use FOR UPDATE NOWAIT to
462 # try to get the lock - fail-fast
463 cr.execute("""SELECT id from res_users
464 WHERE login=%s AND password=%s
465 AND active FOR UPDATE NOWAIT""",
466 (tools.ustr(login), tools.ustr(password)))
467 cr.execute("""UPDATE res_users
468 SET date = now() AT TIME ZONE 'UTC'
469 WHERE login=%s AND password=%s AND active
471 (tools.ustr(login), tools.ustr(password)))
473 # Failing to acquire the lock on the res_users row probably means
474 # another request is holding it. No big deal, we don't want to
475 # prevent/delay login in that case. It will also have been logged
476 # as a SQL error, if anyone cares.
477 cr.execute("""SELECT id from res_users
478 WHERE login=%s AND password=%s
480 (tools.ustr(login), tools.ustr(password)))
488 def check_super(self, passwd):
489 if passwd == tools.config['admin_passwd']:
492 raise openerp.exceptions.AccessDenied()
494 def check(self, db, uid, passwd):
495 """Verifies that the given (uid, password) pair is authorized for the database ``db`` and
496 raise an exception if it is not."""
498 # empty passwords disallowed for obvious security reasons
499 raise openerp.exceptions.AccessDenied()
500 if self._uid_cache.get(db, {}).get(uid) == passwd:
502 cr = pooler.get_db(db).cursor()
504 cr.execute('SELECT COUNT(1) FROM res_users WHERE id=%s AND password=%s AND active=%s',
505 (int(uid), passwd, True))
506 res = cr.fetchone()[0]
508 raise openerp.exceptions.AccessDenied()
509 if self._uid_cache.has_key(db):
510 ulist = self._uid_cache[db]
513 self._uid_cache[db] = {uid:passwd}
517 def access(self, db, uid, passwd, sec_level, ids):
520 cr = pooler.get_db(db).cursor()
522 cr.execute('SELECT id FROM res_users WHERE id=%s AND password=%s', (uid, passwd))
525 raise openerp.exceptions.AccessDenied()
530 def change_password(self, cr, uid, old_passwd, new_passwd, context=None):
531 """Change current user password. Old password must be provided explicitly
532 to prevent hijacking an existing user session, or for cases where the cleartext
533 password is not used to authenticate requests.
536 :raise: openerp.exceptions.AccessDenied when old password is wrong
537 :raise: except_osv when new password is not set or empty
539 self.check(cr.dbname, uid, old_passwd)
541 return self.write(cr, uid, uid, {'password': new_passwd})
542 raise osv.except_osv(_('Warning!'), _("Setting empty passwords is not allowed for security reasons!"))
544 def has_group(self, cr, uid, group_ext_id):
545 """Checks whether user belongs to given group.
547 :param str group_ext_id: external ID (XML ID) of the group.
548 Must be provided in fully-qualified form (``module.ext_id``), as there
549 is no implicit module to use..
550 :return: True if the current user is a member of the group with the
551 given external ID (XML ID), else False.
553 assert group_ext_id and '.' in group_ext_id, "External ID must be fully qualified"
554 module, ext_id = group_ext_id.split('.')
555 cr.execute("""SELECT 1 FROM res_groups_users_rel WHERE uid=%s AND gid IN
556 (SELECT res_id FROM ir_model_data WHERE module=%s AND name=%s)""",
557 (uid, module, ext_id))
558 return bool(cr.fetchone())
565 # Extension of res.groups and res.users with a relation for "implied" or
566 # "inherited" groups. Once a user belongs to a group, it automatically belongs
567 # to the implied groups (transitively).
571 """ A cset (constrained set) is a set of elements that may be constrained to
572 be a subset of other csets. Elements added to a cset are automatically
573 added to its supersets. Cycles in the subset constraints are supported.
575 def __init__(self, xs):
576 self.supersets = set()
577 self.elements = set(xs)
578 def subsetof(self, other):
579 if other is not self:
580 self.supersets.add(other)
581 other.update(self.elements)
582 def update(self, xs):
583 xs = set(xs) - self.elements
584 if xs: # xs will eventually be empty in case of a cycle
585 self.elements.update(xs)
586 for s in self.supersets:
589 return iter(self.elements)
592 """ return the concatenation of a list of iterables """
594 for l in ls: res.extend(l)
599 class groups_implied(osv.osv):
600 _inherit = 'res.groups'
602 def _get_trans_implied(self, cr, uid, ids, field, arg, context=None):
603 "computes the transitive closure of relation implied_ids"
604 memo = {} # use a memo for performance and cycle avoidance
607 memo[g] = cset(g.implied_ids)
608 for h in g.implied_ids:
609 computed_set(h).subsetof(memo[g])
613 for g in self.browse(cr, 1, ids, context):
614 res[g.id] = map(int, computed_set(g))
618 'implied_ids': fields.many2many('res.groups', 'res_groups_implied_rel', 'gid', 'hid',
619 string='Inherits', help='Users of this group automatically inherit those groups'),
620 'trans_implied_ids': fields.function(_get_trans_implied,
621 type='many2many', relation='res.groups', string='Transitively inherits'),
624 def create(self, cr, uid, values, context=None):
625 users = values.pop('users', None)
626 gid = super(groups_implied, self).create(cr, uid, values, context)
628 # delegate addition of users to add implied groups
629 self.write(cr, uid, [gid], {'users': users}, context)
632 def write(self, cr, uid, ids, values, context=None):
633 res = super(groups_implied, self).write(cr, uid, ids, values, context)
634 if values.get('users') or values.get('implied_ids'):
635 # add all implied groups (to all users of each group)
636 for g in self.browse(cr, uid, ids):
637 gids = map(int, g.trans_implied_ids)
638 vals = {'users': [(4, u.id) for u in g.users]}
639 super(groups_implied, self).write(cr, uid, gids, vals, context)
644 class users_implied(osv.osv):
645 _inherit = 'res.users'
647 def create(self, cr, uid, values, context=None):
648 groups = values.pop('groups_id', None)
649 user_id = super(users_implied, self).create(cr, uid, values, context)
651 # delegate addition of groups to add implied groups
652 self.write(cr, uid, [user_id], {'groups_id': groups}, context)
655 def write(self, cr, uid, ids, values, context=None):
656 if not isinstance(ids,list):
658 res = super(users_implied, self).write(cr, uid, ids, values, context)
659 if values.get('groups_id'):
660 # add implied groups for all users
661 for user in self.browse(cr, uid, ids):
662 gs = set(concat([g.trans_implied_ids for g in user.groups_id]))
663 vals = {'groups_id': [(4, g.id) for g in gs]}
664 super(users_implied, self).write(cr, uid, [user.id], vals, context)
672 # Extension of res.groups and res.users for the special groups view in the users
673 # form. This extension presents groups with selection and boolean widgets:
674 # - Groups are shown by application, with boolean and/or selection fields.
675 # Selection fields typically defines a role "Name" for the given application.
676 # - Uncategorized groups are presented as boolean fields and grouped in a
679 # The user form view is modified by an inherited view (base.user_groups_view);
680 # the inherited view replaces the field 'groups_id' by a set of reified group
681 # fields (boolean or selection fields). The arch of that view is regenerated
682 # each time groups are changed.
684 # Naming conventions for reified groups fields:
685 # - boolean field 'in_group_ID' is True iff
686 # ID is in 'groups_id'
687 # - boolean field 'in_groups_ID1_..._IDk' is True iff
688 # any of ID1, ..., IDk is in 'groups_id'
689 # - selection field 'sel_groups_ID1_..._IDk' is ID iff
690 # ID is in 'groups_id' and ID is maximal in the set {ID1, ..., IDk}
692 def name_boolean_group(id): return 'in_group_' + str(id)
693 def name_boolean_groups(ids): return 'in_groups_' + '_'.join(map(str, ids))
694 def name_selection_groups(ids): return 'sel_groups_' + '_'.join(map(str, ids))
696 def is_boolean_group(name): return name.startswith('in_group_')
697 def is_boolean_groups(name): return name.startswith('in_groups_')
698 def is_selection_groups(name): return name.startswith('sel_groups_')
699 def is_reified_group(name):
700 return is_boolean_group(name) or is_boolean_groups(name) or is_selection_groups(name)
702 def get_boolean_group(name): return int(name[9:])
703 def get_boolean_groups(name): return map(int, name[10:].split('_'))
704 def get_selection_groups(name): return map(int, name[11:].split('_'))
706 def partition(f, xs):
707 "return a pair equivalent to (filter(f, xs), filter(lambda x: not f(x), xs))"
710 (yes if f(x) else nos).append(x)
715 class groups_view(osv.osv):
716 _inherit = 'res.groups'
718 def create(self, cr, uid, values, context=None):
719 res = super(groups_view, self).create(cr, uid, values, context)
720 self.update_user_groups_view(cr, uid, context)
723 def write(self, cr, uid, ids, values, context=None):
724 res = super(groups_view, self).write(cr, uid, ids, values, context)
725 self.update_user_groups_view(cr, uid, context)
728 def unlink(self, cr, uid, ids, context=None):
729 res = super(groups_view, self).unlink(cr, uid, ids, context)
730 self.update_user_groups_view(cr, uid, context)
733 def update_user_groups_view(self, cr, uid, context=None):
734 # the view with id 'base.user_groups_view' inherits the user form view,
735 # and introduces the reified group fields
736 view = self.get_user_groups_view(cr, uid, context)
739 xml1.append(E.separator(string=_('Application'), colspan="4"))
740 for app, kind, gs in self.get_groups_by_application(cr, uid, context):
741 # hide groups in category 'Hidden' (except to group_no_one)
742 attrs = {'groups': 'base.group_no_one'} if app and app.xml_id == 'base.module_category_hidden' else {}
743 if kind == 'selection':
744 # application name with a selection field
745 field_name = name_selection_groups(map(int, gs))
746 xml1.append(E.field(name=field_name, **attrs))
747 xml1.append(E.newline())
749 # application separator with boolean fields
750 app_name = app and app.name or _('Other')
751 xml2.append(E.separator(string=app_name, colspan="4", **attrs))
753 field_name = name_boolean_group(g.id)
754 xml2.append(E.field(name=field_name, **attrs))
756 xml = E.field(*(xml1 + xml2), name="groups_id", position="replace")
757 xml.addprevious(etree.Comment("GENERATED AUTOMATICALLY BY GROUPS"))
758 xml_content = etree.tostring(xml, pretty_print=True, xml_declaration=True, encoding="utf-8")
759 view.write({'arch': xml_content})
762 def get_user_groups_view(self, cr, uid, context=None):
764 view = self.pool.get('ir.model.data').get_object(cr, 1, 'base', 'user_groups_view', context)
765 assert view and view._table_name == 'ir.ui.view'
770 def get_application_groups(self, cr, uid, domain=None, context=None):
771 return self.search(cr, uid, domain or [])
773 def get_groups_by_application(self, cr, uid, context=None):
774 """ return all groups classified by application (module category), as a list of pairs:
775 [(app, kind, [group, ...]), ...],
776 where app and group are browse records, and kind is either 'boolean' or 'selection'.
777 Applications are given in sequence order. If kind is 'selection', the groups are
778 given in reverse implication order.
782 # determine sequence order: a group should appear after its implied groups
783 order = dict.fromkeys(gs, 0)
785 for h in gs.intersection(g.trans_implied_ids):
787 # check whether order is total, i.e., sequence orders are distinct
788 if len(set(order.itervalues())) == len(gs):
789 return sorted(gs, key=lambda g: order[g])
792 # classify all groups by application
793 gids = self.get_application_groups(cr, uid, context=context)
794 by_app, others = {}, []
795 for g in self.browse(cr, uid, gids, context):
797 by_app.setdefault(g.category_id, []).append(g)
802 apps = sorted(by_app.iterkeys(), key=lambda a: a.sequence or 0)
804 gs = linearized(by_app[app])
806 res.append((app, 'selection', gs))
808 res.append((app, 'boolean', by_app[app]))
810 res.append((False, 'boolean', others))
815 class users_view(osv.osv):
816 _inherit = 'res.users'
818 def create(self, cr, uid, values, context=None):
819 self._set_reified_groups(values)
820 return super(users_view, self).create(cr, uid, values, context)
822 def write(self, cr, uid, ids, values, context=None):
823 self._set_reified_groups(values)
824 return super(users_view, self).write(cr, uid, ids, values, context)
826 def _set_reified_groups(self, values):
827 """ reflect reified group fields in values['groups_id'] """
828 if 'groups_id' in values:
829 # groups are already given, ignore group fields
830 for f in filter(is_reified_group, values.iterkeys()):
835 for f in values.keys():
836 if is_boolean_group(f):
837 target = add if values.pop(f) else remove
838 target.append(get_boolean_group(f))
839 elif is_boolean_groups(f):
840 if not values.pop(f):
841 remove.extend(get_boolean_groups(f))
842 elif is_selection_groups(f):
843 remove.extend(get_selection_groups(f))
844 selected = values.pop(f)
847 # update values *only* if groups are being modified, otherwise
848 # we introduce spurious changes that might break the super.write() call.
850 # remove groups in 'remove' and add groups in 'add'
851 values['groups_id'] = [(3, id) for id in remove] + [(4, id) for id in add]
853 def default_get(self, cr, uid, fields, context=None):
854 group_fields, fields = partition(is_reified_group, fields)
855 fields1 = (fields + ['groups_id']) if group_fields else fields
856 values = super(users_view, self).default_get(cr, uid, fields1, context)
857 self._get_reified_groups(group_fields, values)
860 def read(self, cr, uid, ids, fields=None, context=None, load='_classic_read'):
862 fields = self.fields_get(cr, uid, context=context).keys()
863 group_fields, fields = partition(is_reified_group, fields)
864 if not 'groups_id' in fields:
865 fields.append('groups_id')
866 res = super(users_view, self).read(cr, uid, ids, fields, context=context, load=load)
867 for values in (res if isinstance(res, list) else [res]):
868 self._get_reified_groups(group_fields, values)
871 def _get_reified_groups(self, fields, values):
872 """ compute the given reified group fields from values['groups_id'] """
873 gids = set(values.get('groups_id') or [])
875 if is_boolean_group(f):
876 values[f] = get_boolean_group(f) in gids
877 elif is_boolean_groups(f):
878 values[f] = not gids.isdisjoint(get_boolean_groups(f))
879 elif is_selection_groups(f):
880 selected = [gid for gid in get_selection_groups(f) if gid in gids]
881 values[f] = selected and selected[-1] or False
883 def fields_get(self, cr, uid, allfields=None, context=None, write_access=True):
884 res = super(users_view, self).fields_get(cr, uid, allfields, context, write_access)
885 # add reified groups fields
886 for app, kind, gs in self.pool.get('res.groups').get_groups_by_application(cr, uid, context):
887 if kind == 'selection':
888 # selection group field
889 tips = ['%s: %s' % (g.name, g.comment or '') for g in gs]
890 res[name_selection_groups(map(int, gs))] = {
892 'string': app and app.name or _('Other'),
893 'selection': [(False, '')] + [(g.id, g.name) for g in gs],
894 'help': '\n'.join(tips),
897 # boolean group fields
899 res[name_boolean_group(g.id)] = {
908 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: