[MERGE] lp:openobject-addons
[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 import io, StringIO
29 from lxml import etree
30 from lxml.builder import E
31 import netsvc
32 import openerp
33 import openerp.exceptions
34 from osv import fields,osv
35 from osv.orm import browse_record
36 from PIL import Image
37 import pooler
38 import random
39 from service import security
40 import tools
41 from tools.translate import _
42
43 _logger = logging.getLogger(__name__)
44
45 class groups(osv.osv):
46     _name = "res.groups"
47     _description = "Access Groups"
48     _rec_name = 'full_name'
49
50     def _get_full_name(self, cr, uid, ids, field, arg, context=None):
51         res = {}
52         for g in self.browse(cr, uid, ids, context):
53             if g.category_id:
54                 res[g.id] = '%s / %s' % (g.category_id.name, g.name)
55             else:
56                 res[g.id] = g.name
57         return res
58
59     _columns = {
60         'name': fields.char('Name', size=64, required=True, translate=True),
61         'users': fields.many2many('res.users', 'res_groups_users_rel', 'gid', 'uid', 'Users'),
62         'model_access': fields.one2many('ir.model.access', 'group_id', 'Access Controls'),
63         'rule_groups': fields.many2many('ir.rule', 'rule_group_rel',
64             'group_id', 'rule_group_id', 'Rules', domain=[('global', '=', False)]),
65         'menu_access': fields.many2many('ir.ui.menu', 'ir_ui_menu_group_rel', 'gid', 'menu_id', 'Access Menu'),
66         'comment' : fields.text('Comment', size=250, translate=True),
67         'category_id': fields.many2one('ir.module.category', 'Application', select=True),
68         'full_name': fields.function(_get_full_name, type='char', string='Group Name'),
69     }
70
71     _sql_constraints = [
72         ('name_uniq', 'unique (category_id, name)', 'The name of the group must be unique !')
73     ]
74
75     def search(self, cr, uid, args, offset=0, limit=None, order=None, context=None, count=False):
76         # add explicit ordering if search is sorted on full_name
77         if order and order.startswith('full_name'):
78             ids = super(groups, self).search(cr, uid, args, context=context)
79             gs = self.browse(cr, uid, ids, context)
80             gs.sort(key=lambda g: g.full_name, reverse=order.endswith('DESC'))
81             gs = gs[offset:offset+limit] if limit else gs[offset:]
82             return map(int, gs)
83         return super(groups, self).search(cr, uid, args, offset, limit, order, context, count)
84
85     def copy(self, cr, uid, id, default=None, context=None):
86         group_name = self.read(cr, uid, [id], ['name'])[0]['name']
87         default.update({'name': _('%s (copy)')%group_name})
88         return super(groups, self).copy(cr, uid, id, default, context)
89
90     def write(self, cr, uid, ids, vals, context=None):
91         if 'name' in vals:
92             if vals['name'].startswith('-'):
93                 raise osv.except_osv(_('Error'),
94                         _('The name of the group can not start with "-"'))
95         res = super(groups, self).write(cr, uid, ids, vals, context=context)
96         self.pool.get('ir.model.access').call_cache_clearing_methods(cr)
97         return res
98
99 groups()
100
101 def _lang_get(self, cr, uid, context=None):
102     obj = self.pool.get('res.lang')
103     ids = obj.search(cr, uid, [('translatable','=',True)])
104     res = obj.read(cr, uid, ids, ['code', 'name'], context=context)
105     res = [(r['code'], r['name']) for r in res]
106     return res
107
108 def _tz_get(self,cr,uid, context=None):
109     return [(x, x) for x in pytz.all_timezones]
110
111 class users(osv.osv):
112     __admin_ids = {}
113     _uid_cache = {}
114     _name = "res.users"
115     _description = 'Users'
116     _order = 'name'
117
118     WELCOME_MAIL_SUBJECT = u"Welcome to OpenERP"
119     WELCOME_MAIL_BODY = u"An OpenERP account has been created for you, "\
120         "\"%(name)s\".\n\nYour login is %(login)s, "\
121         "you should ask your supervisor or system administrator if you "\
122         "haven't been given your password yet.\n\n"\
123         "If you aren't %(name)s, this email reached you errorneously, "\
124         "please delete it."
125
126     def get_welcome_mail_subject(self, cr, uid, context=None):
127         """ Returns the subject of the mail new users receive (when
128         created via the res.config.users wizard), default implementation
129         is to return config_users.WELCOME_MAIL_SUBJECT
130         """
131         return self.WELCOME_MAIL_SUBJECT
132     def get_welcome_mail_body(self, cr, uid, context=None):
133         """ Returns the subject of the mail new users receive (when
134         created via the res.config.users wizard), default implementation
135         is to return config_users.WELCOME_MAIL_BODY
136         """
137         return self.WELCOME_MAIL_BODY
138
139     def get_current_company(self, cr, uid):
140         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         return cr.fetchall()
142
143     def send_welcome_email(self, cr, uid, id, context=None):
144         if isinstance(id,list): id = id[0]
145         user = self.read(cr, uid, id, ['email','login','name', 'user_email'], context=context)
146         email = user['email'] or user['user_email']
147
148         ir_mail_server = self.pool.get('ir.mail_server')
149         msg = ir_mail_server.build_email(email_from=None, # take config default
150                                          email_to=[email],
151                                          subject=self.get_welcome_mail_subject(cr, uid, context=context),
152                                          body=(self.get_welcome_mail_body(cr, uid, context=context) % user))
153         return ir_mail_server.send_email(cr, uid, msg, context=context)
154
155     def onchange_avatar(self, cr, uid, ids, value, context=None):
156         if not value:
157             return {'value': {'avatar_big': value, 'avatar': value} }
158         return {'value': {'avatar_big': self._avatar_resize(cr, uid, value, 540, 450, context=context), 'avatar': self._avatar_resize(cr, uid, value, context=context)} }
159     
160     def _set_avatar(self, cr, uid, id, name, value, args, context=None):
161         if not value:
162             vals = {'avatar_big': value}
163         else:
164             vals = {'avatar_big': self._avatar_resize(cr, uid, value, 540, 450, context=context)}
165         return self.write(cr, uid, [id], vals, context=context)
166     
167     def _avatar_resize(self, cr, uid, avatar, height=180, width=150, context=None):
168         image_stream = io.BytesIO(avatar.decode('base64'))
169         img = Image.open(image_stream)
170         img.thumbnail((height, width), Image.ANTIALIAS)
171         img_stream = StringIO.StringIO()
172         img.save(img_stream, "PNG")
173         return img_stream.getvalue().encode('base64')
174
175     def _get_avatar(self, cr, uid, ids, name, args, context=None):
176         result = dict.fromkeys(ids, False)
177         for user in self.browse(cr, uid, ids, context=context):
178             if user.avatar_big:
179                 result[user.id] = self._avatar_resize(cr, uid, user.avatar_big, context=context)
180         return result
181
182     def _set_new_password(self, cr, uid, id, name, value, args, context=None):
183         if value is False:
184             # Do not update the password if no value is provided, ignore silently.
185             # For example web client submits False values for all empty fields.
186             return
187         if uid == id:
188             # To change their own password users must use the client-specific change password wizard,
189             # so that the new password is immediately used for further RPC requests, otherwise the user
190             # will face unexpected 'Access Denied' exceptions.
191             raise osv.except_osv(_('Operation Canceled'), _('Please use the change password wizard (in User Preferences or User menu) to change your own password.'))
192         self.write(cr, uid, id, {'password': value})
193
194     def _get_password(self, cr, uid, ids, arg, karg, context=None):
195         return dict.fromkeys(ids, '')
196
197     _columns = {
198         'id': fields.integer('ID'),
199         'name': fields.char('User Name', size=64, required=True, select=True,
200                             help="The new user's real name, used for searching"
201                                  " and most listings"),
202         'login': fields.char('Login', size=64, required=True,
203                              help="Used to log into the system"),
204         '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."),
205         'new_password': fields.function(_get_password, type='char', size=64,
206                                 fnct_inv=_set_new_password,
207                                 string='Set Password', help="Specify a value only when creating a user or if you're changing the user's password, "
208                                                             "otherwise leave empty. After a change of password, the user has to login again."),
209         'user_email': fields.char('Email', size=64),
210         'signature': fields.text('Signature', size=64),
211         '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."),
212         'avatar': fields.function(_get_avatar, fnct_inv=_set_avatar, string='Avatar', type="binary",
213             store = {
214                 'res.users': (lambda self, cr, uid, ids, c={}: ids, ['avatar_big'], 10),
215             }, 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."),
216         'active': fields.boolean('Active'),
217         '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."),
218         'menu_id': fields.many2one('ir.actions.actions', 'Menu Action', help="If specified, the action will replace the standard menu for this user."),
219         'groups_id': fields.many2many('res.groups', 'res_groups_users_rel', 'uid', 'gid', 'Groups'),
220
221         # Special behavior for this field: res.company.search() will only return the companies
222         # available to the current user (should be the user's companies?), when the user_preference
223         # context is set.
224         'company_id': fields.many2one('res.company', 'Company', required=True,
225             help="The company this user is currently working for.", context={'user_preference': True}),
226
227         'company_ids':fields.many2many('res.company','res_company_users_rel','user_id','cid','Companies'),
228         'context_lang': fields.selection(_lang_get, 'Language', required=True,
229             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."),
230         'context_tz': fields.selection(_tz_get,  'Timezone', size=64,
231             help="The user's timezone, used to output proper date and time values inside printed reports. "
232                  "It is important to set a value for this field. You should use the same timezone "
233                  "that is otherwise used to pick and render date and time values: your computer's timezone."),
234         'menu_tips': fields.boolean('Menu Tips', help="Check out this box if you want to always display tips on each menu action"),
235         'date': fields.datetime('Latest Connection', readonly=True),
236     }
237
238     def on_change_company_id(self, cr, uid, ids, company_id):
239         return {
240                 'warning' : {
241                     'title': _("Company Switch Warning"),
242                     '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)"),
243                 }
244         }
245
246     def read(self,cr, uid, ids, fields=None, context=None, load='_classic_read'):
247         def override_password(o):
248             if 'password' in o and ( 'id' not in o or o['id'] != uid ):
249                 o['password'] = '********'
250             return o
251         result = super(users, self).read(cr, uid, ids, fields, context, load)
252         canwrite = self.pool.get('ir.model.access').check(cr, uid, 'res.users', 'write', False)
253         if not canwrite:
254             if isinstance(ids, (int, float)):
255                 result = override_password(result)
256             else:
257                 result = map(override_password, result)
258         return result
259
260
261     def _check_company(self, cr, uid, ids, context=None):
262         return all(((this.company_id in this.company_ids) or not this.company_ids) for this in self.browse(cr, uid, ids, context))
263
264     _constraints = [
265         (_check_company, 'The chosen company is not in the allowed companies for this user', ['company_id', 'company_ids']),
266     ]
267
268     _sql_constraints = [
269         ('login_key', 'UNIQUE (login)',  'You can not have two users with the same login !')
270     ]
271
272     def _get_email_from(self, cr, uid, ids, context=None):
273         if not isinstance(ids, list):
274             ids = [ids]
275         res = dict.fromkeys(ids, False)
276         for user in self.browse(cr, uid, ids, context=context):
277             if user.user_email:
278                 res[user.id] = "%s <%s>" % (user.name, user.user_email)
279         return res
280
281     def _get_admin_id(self, cr):
282         if self.__admin_ids.get(cr.dbname) is None:
283             ir_model_data_obj = self.pool.get('ir.model.data')
284             mdid = ir_model_data_obj._get_id(cr, 1, 'base', 'user_root')
285             self.__admin_ids[cr.dbname] = ir_model_data_obj.read(cr, 1, [mdid], ['res_id'])[0]['res_id']
286         return self.__admin_ids[cr.dbname]
287
288     def _get_company(self,cr, uid, context=None, uid2=False):
289         if not uid2:
290             uid2 = uid
291         user = self.pool.get('res.users').read(cr, uid, uid2, ['company_id'], context)
292         company_id = user.get('company_id', False)
293         return company_id and company_id[0] or False
294
295     def _get_companies(self, cr, uid, context=None):
296         c = self._get_company(cr, uid, context)
297         if c:
298             return [c]
299         return False
300
301     def _get_menu(self,cr, uid, context=None):
302         dataobj = self.pool.get('ir.model.data')
303         try:
304             model, res_id = dataobj.get_object_reference(cr, uid, 'base', 'action_menu_admin')
305             if model != 'ir.actions.act_window':
306                 return False
307             return res_id
308         except ValueError:
309             return False
310
311     def _get_group(self,cr, uid, context=None):
312         dataobj = self.pool.get('ir.model.data')
313         result = []
314         try:
315             dummy,group_id = dataobj.get_object_reference(cr, 1, 'base', 'group_user')
316             result.append(group_id)
317             dummy,group_id = dataobj.get_object_reference(cr, 1, 'base', 'group_partner_manager')
318             result.append(group_id)
319         except ValueError:
320             # If these groups does not exists anymore
321             pass
322         return result
323
324     def _get_avatar(self, cr, uid, context=None):
325         # default avatar file name: avatar0 -> avatar6.png, choose randomly
326         avatar_path = openerp.modules.get_module_resource('base', 'static/src/img', 'avatar%d.png' % random.randint(0, 6))
327         return self._avatar_resize(cr, uid, open(avatar_path, 'rb').read().encode('base64'), context=context)
328
329     _defaults = {
330         'password' : '',
331         'context_lang': lambda self, cr, uid, context: context.get('lang', 'en_US'),
332         'context_tz': lambda self, cr, uid, context: context.get('tz', False),
333         'avatar': _get_avatar,
334         'active' : True,
335         'menu_id': _get_menu,
336         'company_id': _get_company,
337         'company_ids': _get_companies,
338         'groups_id': _get_group,
339         'menu_tips': False
340     }
341
342     # User can write to a few of her own fields (but not her groups for example)
343     SELF_WRITEABLE_FIELDS = ['menu_tips','password', 'signature', 'action_id', 'company_id', 'user_email', 'name', 'avatar', 'avatar_big']
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: