[CLEAN] image help: cleaned typos.
[odoo/odoo.git] / openerp / addons / base / res / res_users.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2009 Tiny SPRL (<http://tiny.be>).
6 #    Copyright (C) 2010-2011 OpenERP s.a. (<http://openerp.com>).
7 #
8 #    This program is free software: you can redistribute it and/or modify
9 #    it under the terms of the GNU Affero General Public License as
10 #    published by the Free Software Foundation, either version 3 of the
11 #    License, or (at your option) any later version.
12 #
13 #    This program is distributed in the hope that it will be useful,
14 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
15 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 #    GNU Affero General Public License for more details.
17 #
18 #    You should have received a copy of the GNU Affero General Public License
19 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
20 #
21 ##############################################################################
22
23 import logging
24 from functools import partial
25
26 import pytz
27
28 from lxml import etree
29 from lxml.builder import E
30 import netsvc
31 import openerp
32 import openerp.exceptions
33 from osv import fields,osv
34 from osv.orm import browse_record
35 import pooler
36 import random
37 from service import security
38 import tools
39 from tools.translate import _
40
41 _logger = logging.getLogger(__name__)
42
43 class groups(osv.osv):
44     _name = "res.groups"
45     _description = "Access Groups"
46     _rec_name = 'full_name'
47
48     def _get_full_name(self, cr, uid, ids, field, arg, context=None):
49         res = {}
50         for g in self.browse(cr, uid, ids, context):
51             if g.category_id:
52                 res[g.id] = '%s / %s' % (g.category_id.name, g.name)
53             else:
54                 res[g.id] = g.name
55         return res
56
57     _columns = {
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'),
67     }
68
69     _sql_constraints = [
70         ('name_uniq', 'unique (category_id, name)', 'The name of the group must be unique !')
71     ]
72
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:]
80             return map(int, gs)
81         return super(groups, self).search(cr, uid, args, offset, limit, order, context, count)
82
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)
87
88     def write(self, cr, uid, ids, vals, context=None):
89         if 'name' in vals:
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)
95         return res
96
97 groups()
98
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]
104     return res
105
106 def _tz_get(self,cr,uid, context=None):
107     return [(x, x) for x in pytz.all_timezones]
108
109 class users(osv.osv):
110     __admin_ids = {}
111     _uid_cache = {}
112     _name = "res.users"
113     _description = 'Users'
114     _order = 'name'
115
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, "\
122         "please delete it."
123
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
128         """
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
134         """
135         return self.WELCOME_MAIL_BODY
136
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)
139         return cr.fetchall()
140
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']
145
146         ir_mail_server = self.pool.get('ir.mail_server')
147         msg = ir_mail_server.build_email(email_from=None, # take config default
148                                          email_to=[email],
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)
152
153     def _set_new_password(self, cr, uid, id, name, value, args, context=None):
154         if value is False:
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.
157             return
158         if uid == id:
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})
164
165     def _get_password(self, cr, uid, ids, arg, karg, context=None):
166         return dict.fromkeys(ids, '')
167
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)
172             result[obj.id] = {
173                 'image_medium': resized_image_dict['image_medium'],
174                 'image_small': resized_image_dict['image_small'],
175                 }
176         return result
177     
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)
180     
181     def onchange_image(self, cr, uid, ids, value, context=None):
182         return {'value': tools.get_resized_images(value)}
183     
184     _columns = {
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",
204             store = {
205                 'res.users': (lambda self, cr, uid, ids, c={}: ids, ['image'], 10),
206             },
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",
212             store = {
213                 'res.users': (lambda self, cr, uid, ids, c={}: ids, ['image'], 10),
214             },
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'),
222
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
225         # context is set.
226         'company_id': fields.many2one('res.company', 'Company', required=True,
227             help="The company this user is currently working for.", context={'user_preference': True}),
228
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),
237     }
238
239     def on_change_company_id(self, cr, uid, ids, company_id):
240         return {
241                 'warning' : {
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)"),
244                 }
245         }
246
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'] = '********'
251             return o
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)
254         if not canwrite:
255             if isinstance(ids, (int, float)):
256                 result = override_password(result)
257             else:
258                 result = map(override_password, result)
259         return result
260
261
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))
264
265     _constraints = [
266         (_check_company, 'The chosen company is not in the allowed companies for this user', ['company_id', 'company_ids']),
267     ]
268
269     _sql_constraints = [
270         ('login_key', 'UNIQUE (login)',  'You can not have two users with the same login !')
271     ]
272
273     def _get_email_from(self, cr, uid, ids, context=None):
274         if not isinstance(ids, list):
275             ids = [ids]
276         res = dict.fromkeys(ids, False)
277         for user in self.browse(cr, uid, ids, context=context):
278             if user.user_email:
279                 res[user.id] = "%s <%s>" % (user.name, user.user_email)
280         return res
281
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]
288
289     def _get_company(self,cr, uid, context=None, uid2=False):
290         if not uid2:
291             uid2 = uid
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
295
296     def _get_companies(self, cr, uid, context=None):
297         c = self._get_company(cr, uid, context)
298         if c:
299             return [c]
300         return False
301
302     def _get_menu(self,cr, uid, context=None):
303         dataobj = self.pool.get('ir.model.data')
304         try:
305             model, res_id = dataobj.get_object_reference(cr, uid, 'base', 'action_menu_admin')
306             if model != 'ir.actions.act_window':
307                 return False
308             return res_id
309         except ValueError:
310             return False
311
312     def _get_group(self,cr, uid, context=None):
313         dataobj = self.pool.get('ir.model.data')
314         result = []
315         try:
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)
320         except ValueError:
321             # If these groups does not exists anymore
322             pass
323         return result
324
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'))
329
330     _defaults = {
331         'password' : '',
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,
335         'active' : True,
336         'menu_id': _get_menu,
337         'company_id': _get_company,
338         'company_ids': _get_companies,
339         'groups_id': _get_group,
340     }
341
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']
344
345     def write(self, cr, uid, ids, values, context=None):
346         if not hasattr(ids, '__iter__'):
347             ids = [ids]
348         if ids == [uid]:
349             for key in values.keys():
350                 if not (key in self.SELF_WRITEABLE_FIELDS or key.startswith('context_')):
351                     break
352             else:
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
357
358         res = super(users, self).write(cr, uid, ids, values, context=context)
359
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)
363         map(clear, ids)
364         db = cr.dbname
365         if db in self._uid_cache:
366             for id in ids:
367                 if id in self._uid_cache[db]:
368                     del self._uid_cache[db][id]
369
370         return res
371
372     def unlink(self, cr, uid, ids, context=None):
373         if 1 in ids:
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, ...)'))
375         db = cr.dbname
376         if db in self._uid_cache:
377             for id in ids:
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)
381
382     def name_search(self, cr, user, name='', args=None, operator='ilike', context=None, limit=100):
383         if not args:
384             args=[]
385         if not context:
386             context={}
387         ids = []
388         if name:
389             ids = self.search(cr, user, [('login','=',name)]+ args, limit=limit)
390         if not ids:
391             ids = self.search(cr, user, [('name',operator,name)]+ args, limit=limit)
392         return self.name_get(cr, user, ids)
393
394     def copy(self, cr, uid, id, default=None, context=None):
395         user2copy = self.read(cr, uid, [id], ['login','name'])[0]
396         if default is None:
397             default = {}
398         copy_pattern = _("%s (copy)")
399         copydef = dict(login=(copy_pattern % user2copy['login']),
400                        name=(copy_pattern % user2copy['name']),
401                        )
402         copydef.update(default)
403         return super(users, self).copy(cr, uid, id, copydef, context)
404
405     def context_get(self, cr, uid, context=None):
406         user = self.browse(cr, uid, uid, context)
407         result = {}
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):
412                     res = res.id
413                 result[k[8:]] = res or False
414         return result
415
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
420
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
424           no matching user.
425
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
431         """
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()
438                 try:
439                     self.pool.get('ir.config_parameter').set_param(cr, uid, 'web.base.url',
440                                                                    user_agent_env['base_location'])
441                     cr.commit()
442                 except Exception:
443                     _logger.exception("Failed to update web.base.url configuration parameter")
444                 finally:
445                     cr.close()
446         return uid
447
448     def login(self, db, login, password):
449         if not password:
450             return False
451         cr = pooler.get_db(db).cursor()
452         try:
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.
458             cr.autocommit(True)
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
470                             RETURNING id""",
471                        (tools.ustr(login), tools.ustr(password)))
472         except Exception:
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
479                                 AND active""",
480                        (tools.ustr(login), tools.ustr(password)))
481         finally:
482             res = cr.fetchone()
483             cr.close()
484             if res:
485                 return res[0]
486         return False
487
488     def check_super(self, passwd):
489         if passwd == tools.config['admin_passwd']:
490             return True
491         else:
492             raise openerp.exceptions.AccessDenied()
493
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."""
497         if not passwd:
498             # empty passwords disallowed for obvious security reasons
499             raise openerp.exceptions.AccessDenied()
500         if self._uid_cache.get(db, {}).get(uid) == passwd:
501             return
502         cr = pooler.get_db(db).cursor()
503         try:
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]
507             if not res:
508                 raise openerp.exceptions.AccessDenied()
509             if self._uid_cache.has_key(db):
510                 ulist = self._uid_cache[db]
511                 ulist[uid] = passwd
512             else:
513                 self._uid_cache[db] = {uid:passwd}
514         finally:
515             cr.close()
516
517     def access(self, db, uid, passwd, sec_level, ids):
518         if not passwd:
519             return False
520         cr = pooler.get_db(db).cursor()
521         try:
522             cr.execute('SELECT id FROM res_users WHERE id=%s AND password=%s', (uid, passwd))
523             res = cr.fetchone()
524             if not res:
525                 raise openerp.exceptions.AccessDenied()
526             return res[0]
527         finally:
528             cr.close()
529
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.
534
535         :return: True
536         :raise: openerp.exceptions.AccessDenied when old password is wrong
537         :raise: except_osv when new password is not set or empty
538         """
539         self.check(cr.dbname, uid, old_passwd)
540         if new_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!"))
543
544     def has_group(self, cr, uid, group_ext_id):
545         """Checks whether user belongs to given group.
546
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.
552         """
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())
559
560 users()
561
562
563
564 #
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).
568 #
569
570 class cset(object):
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.
574     """
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:
587                 s.update(xs)
588     def __iter__(self):
589         return iter(self.elements)
590
591 def concat(ls):
592     """ return the concatenation of a list of iterables """
593     res = []
594     for l in ls: res.extend(l)
595     return res
596
597
598
599 class groups_implied(osv.osv):
600     _inherit = 'res.groups'
601
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
605         def computed_set(g):
606             if g not in memo:
607                 memo[g] = cset(g.implied_ids)
608                 for h in g.implied_ids:
609                     computed_set(h).subsetof(memo[g])
610             return memo[g]
611
612         res = {}
613         for g in self.browse(cr, 1, ids, context):
614             res[g.id] = map(int, computed_set(g))
615         return res
616
617     _columns = {
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'),
622     }
623
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)
627         if users:
628             # delegate addition of users to add implied groups
629             self.write(cr, uid, [gid], {'users': users}, context)
630         return gid
631
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)
640         return res
641
642 groups_implied()
643
644 class users_implied(osv.osv):
645     _inherit = 'res.users'
646
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)
650         if groups:
651             # delegate addition of groups to add implied groups
652             self.write(cr, uid, [user_id], {'groups_id': groups}, context)
653         return user_id
654
655     def write(self, cr, uid, ids, values, context=None):
656         if not isinstance(ids,list):
657             ids = [ids]
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)
665         return res
666
667 users_implied()
668
669
670
671 #
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
677 #   section "Others".
678 #
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.
683 #
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}
691
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))
695
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)
701
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('_'))
705
706 def partition(f, xs):
707     "return a pair equivalent to (filter(f, xs), filter(lambda x: not f(x), xs))"
708     yes, nos = [], []
709     for x in xs:
710         (yes if f(x) else nos).append(x)
711     return yes, nos
712
713
714
715 class groups_view(osv.osv):
716     _inherit = 'res.groups'
717
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)
721         return res
722
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)
726         return res
727
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)
731         return res
732
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)
737         if view:
738             xml1, xml2 = [], []
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())
748                 else:
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))
752                     for g in gs:
753                         field_name = name_boolean_group(g.id)
754                         xml2.append(E.field(name=field_name, **attrs))
755
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})
760         return True
761
762     def get_user_groups_view(self, cr, uid, context=None):
763         try:
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'
766         except Exception:
767             view = False
768         return view
769
770     def get_application_groups(self, cr, uid, domain=None, context=None):
771         return self.search(cr, uid, domain or [])
772
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.
779         """
780         def linearized(gs):
781             gs = set(gs)
782             # determine sequence order: a group should appear after its implied groups
783             order = dict.fromkeys(gs, 0)
784             for g in gs:
785                 for h in gs.intersection(g.trans_implied_ids):
786                     order[h] -= 1
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])
790             return None
791
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):
796             if g.category_id:
797                 by_app.setdefault(g.category_id, []).append(g)
798             else:
799                 others.append(g)
800         # build the result
801         res = []
802         apps = sorted(by_app.iterkeys(), key=lambda a: a.sequence or 0)
803         for app in apps:
804             gs = linearized(by_app[app])
805             if gs:
806                 res.append((app, 'selection', gs))
807             else:
808                 res.append((app, 'boolean', by_app[app]))
809         if others:
810             res.append((False, 'boolean', others))
811         return res
812
813 groups_view()
814
815 class users_view(osv.osv):
816     _inherit = 'res.users'
817
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)
821
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)
825
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()):
831                 del values[f]
832             return
833
834         add, remove = [], []
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)
845                 if selected:
846                     add.append(selected)
847         # update values *only* if groups are being modified, otherwise
848         # we introduce spurious changes that might break the super.write() call.
849         if add or remove:
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]
852
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)
858         return values
859
860     def read(self, cr, uid, ids, fields=None, context=None, load='_classic_read'):
861         if not fields:
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)
869         return res
870
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 [])
874         for f in fields:
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
882
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))] = {
891                     'type': 'selection',
892                     'string': app and app.name or _('Other'),
893                     'selection': [(False, '')] + [(g.id, g.name) for g in gs],
894                     'help': '\n'.join(tips),
895                 }
896             else:
897                 # boolean group fields
898                 for g in gs:
899                     res[name_boolean_group(g.id)] = {
900                         'type': 'boolean',
901                         'string': g.name,
902                         'help': g.comment,
903                     }
904         return res
905
906 users_view()
907
908 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: