[IMP] res.users: default form view is now partner form view. This is done directly...
[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 from service import security
37 import tools
38 from tools.translate import _
39
40 _logger = logging.getLogger(__name__)
41
42 class groups(osv.osv):
43     _name = "res.groups"
44     _description = "Access Groups"
45     _rec_name = 'full_name'
46
47     def _get_full_name(self, cr, uid, ids, field, arg, context=None):
48         res = {}
49         for g in self.browse(cr, uid, ids, context):
50             if g.category_id:
51                 res[g.id] = '%s / %s' % (g.category_id.name, g.name)
52             else:
53                 res[g.id] = g.name
54         return res
55
56     _columns = {
57         'name': fields.char('Name', size=64, required=True, translate=True),
58         'users': fields.many2many('res.users', 'res_groups_users_rel', 'gid', 'uid', 'Users'),
59         'model_access': fields.one2many('ir.model.access', 'group_id', 'Access Controls'),
60         'rule_groups': fields.many2many('ir.rule', 'rule_group_rel',
61             'group_id', 'rule_group_id', 'Rules', domain=[('global', '=', False)]),
62         'menu_access': fields.many2many('ir.ui.menu', 'ir_ui_menu_group_rel', 'gid', 'menu_id', 'Access Menu'),
63         'comment' : fields.text('Comment', size=250, translate=True),
64         'category_id': fields.many2one('ir.module.category', 'Application', select=True),
65         'full_name': fields.function(_get_full_name, type='char', string='Group Name'),
66     }
67
68     _sql_constraints = [
69         ('name_uniq', 'unique (category_id, name)', 'The name of the group must be unique !')
70     ]
71
72     def search(self, cr, uid, args, offset=0, limit=None, order=None, context=None, count=False):
73         # add explicit ordering if search is sorted on full_name
74         if order and order.startswith('full_name'):
75             ids = super(groups, self).search(cr, uid, args, context=context)
76             gs = self.browse(cr, uid, ids, context)
77             gs.sort(key=lambda g: g.full_name, reverse=order.endswith('DESC'))
78             gs = gs[offset:offset+limit] if limit else gs[offset:]
79             return map(int, gs)
80         return super(groups, self).search(cr, uid, args, offset, limit, order, context, count)
81
82     def copy(self, cr, uid, id, default=None, context=None):
83         group_name = self.read(cr, uid, [id], ['name'])[0]['name']
84         default.update({'name': _('%s (copy)')%group_name})
85         return super(groups, self).copy(cr, uid, id, default, context)
86
87     def write(self, cr, uid, ids, vals, context=None):
88         if 'name' in vals:
89             if vals['name'].startswith('-'):
90                 raise osv.except_osv(_('Error'),
91                         _('The name of the group can not start with "-"'))
92         res = super(groups, self).write(cr, uid, ids, vals, context=context)
93         self.pool.get('ir.model.access').call_cache_clearing_methods(cr)
94         return res
95
96 groups()
97
98 class users(osv.osv):
99     """ User class. A res.users record models an OpenERP user and is different
100         from an employee.
101
102         res.users class now inherits from res.partner. The partner model is
103         used to store the data related to the partner: lang, name, address,
104         avatar, ... The user model is now dedicated to technical data.
105     """
106     __admin_ids = {}
107     _uid_cache = {}
108     _inherits = {
109         'res.partner': 'partner_id',
110     }
111     _name = "res.users"
112     _description = 'Users'
113     _order = 'name'
114
115     def _set_new_password(self, cr, uid, id, name, value, args, context=None):
116         if value is False:
117             # Do not update the password if no value is provided, ignore silently.
118             # For example web client submits False values for all empty fields.
119             return
120         if uid == id:
121             # To change their own password users must use the client-specific change password wizard,
122             # so that the new password is immediately used for further RPC requests, otherwise the user
123             # will face unexpected 'Access Denied' exceptions.
124             raise osv.except_osv(_('Operation Canceled'), _('Please use the change password wizard (in User Preferences or User menu) to change your own password.'))
125         self.write(cr, uid, id, {'password': value})
126
127     def _get_password(self, cr, uid, ids, arg, karg, context=None):
128         return dict.fromkeys(ids, '')
129     
130     _columns = {
131         'id': fields.integer('ID'),
132         'login_date': fields.date('Latest connection', select=1),
133         'partner_id': fields.many2one('res.partner', required=True,
134             string='Related Partner', ondelete='cascade',
135             help='Partner-related data of the user'),
136         'login': fields.char('Login', size=64, required=True,
137             help="Used to log into the system"),
138         'password': fields.char('Password', size=64, invisible=True,
139             help="Keep empty if you don't want the user to be able to connect on the system."),
140         'new_password': fields.function(_get_password, type='char', size=64,
141             fnct_inv=_set_new_password, string='Set Password',
142             help="Specify a value only when creating a user or if you're "\
143                  "changing the user's password, otherwise leave empty. After "\
144                  "a change of password, the user has to login again."),
145         'signature': fields.text('Signature', size=64),
146         'active': fields.boolean('Active'),
147         '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."),
148         'menu_id': fields.many2one('ir.actions.actions', 'Menu Action', help="If specified, the action will replace the standard menu for this user."),
149         'groups_id': fields.many2many('res.groups', 'res_groups_users_rel', 'uid', 'gid', 'Groups'),
150         # Special behavior for this field: res.company.search() will only return the companies
151         # available to the current user (should be the user's companies?), when the user_preference
152         # context is set.
153         'company_id': fields.many2one('res.company', 'Company', required=True,
154             help='The company this user is currently working for.', context={'user_preference': True}),
155         # 'context_company_id': fields.related('context_company_id', rel='res.company', 
156         #     string='Company', required=True, context={'user_preference': True},
157         #     help='The company this user is currently working for.',
158         #     deprecated='Use the context_company_id field instead. This field will be removed as of OpenERP 7.1.'),
159         'company_ids':fields.many2many('res.company','res_company_users_rel','user_id','cid','Companies'),
160         # backward compatibility fields
161         'user_email': fields.related('email', type='char',
162             deprecated='Use the email field instead of user_email. This field will be removed as of OpenERP 7.1.'),
163         'date': fields.related('date', type='date', store=True,
164             deprecated='use the login_date field instead of date. This field will be removed as of OpenERP 7.1.'),
165     }
166
167     def on_change_company_id(self, cr, uid, ids, company_id):
168         return {'warning' : {
169                     'title': _("Company Switch Warning"),
170                     '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)"),
171                 }
172         }
173
174     def read(self,cr, uid, ids, fields=None, context=None, load='_classic_read'):
175         def override_password(o):
176             if 'password' in o and ( 'id' not in o or o['id'] != uid ):
177                 o['password'] = '********'
178             return o
179         result = super(users, self).read(cr, uid, ids, fields, context, load)
180         canwrite = self.pool.get('ir.model.access').check(cr, uid, 'res.users', 'write', False)
181         if not canwrite:
182             if isinstance(ids, (int, float)):
183                 result = override_password(result)
184             else:
185                 result = map(override_password, result)
186         return result
187
188
189     def _check_company(self, cr, uid, ids, context=None):
190         return all(((this.company_id in this.company_ids) or not this.company_ids) for this in self.browse(cr, uid, ids, context))
191
192     _constraints = [
193         (_check_company, 'The chosen company is not in the allowed companies for this user', ['company_id', 'company_ids']),
194     ]
195
196     _sql_constraints = [
197         ('login_key', 'UNIQUE (login)',  'You can not have two users with the same login !')
198     ]
199
200     def _get_company(self,cr, uid, context=None, uid2=False):
201         if not uid2:
202             uid2 = uid
203         user = self.pool.get('res.users').read(cr, uid, uid2, ['company_id'], context)
204         company_id = user.get('company_id', False)
205         return company_id and company_id[0] or False
206
207     def _get_companies(self, cr, uid, context=None):
208         c = self._get_company(cr, uid, context)
209         if c:
210             return [c]
211         return False
212
213     def _get_menu(self,cr, uid, context=None):
214         dataobj = self.pool.get('ir.model.data')
215         try:
216             model, res_id = dataobj.get_object_reference(cr, uid, 'base', 'action_menu_admin')
217             if model != 'ir.actions.act_window':
218                 return False
219             return res_id
220         except ValueError:
221             return False
222
223     def _get_group(self,cr, uid, context=None):
224         dataobj = self.pool.get('ir.model.data')
225         result = []
226         try:
227             dummy,group_id = dataobj.get_object_reference(cr, 1, 'base', 'group_user')
228             result.append(group_id)
229             dummy,group_id = dataobj.get_object_reference(cr, 1, 'base', 'group_partner_manager')
230             result.append(group_id)
231         except ValueError:
232             # If these groups does not exists anymore
233             pass
234         return result
235
236     _defaults = {
237         'password' : '',
238         'active' : True,
239         'customer': False,
240         'menu_id': _get_menu,
241         'company_id': _get_company,
242         'company_ids': _get_companies,
243         'groups_id': _get_group,
244     }
245
246     def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
247         """ Override of res.users fields_view_get.
248             - if the view is specified: resume with normal behavior
249             - else: the default view is overrided and redirected to the partner
250               view
251         """
252         print view_id
253         print view_type
254         if not view_id and view_type == 'form':
255             print 'acacpornief'
256             return self.pool.get('res.partner').fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu)
257         return super(users, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu)
258
259     # User can write to a few of her own fields (but not her groups for example)
260     SELF_WRITEABLE_FIELDS = ['password', 'signature', 'action_id', 'company_id', 'email', 'name', 'image', 'image_medium', 'image_small']
261
262     def write(self, cr, uid, ids, values, context=None):
263         if not hasattr(ids, '__iter__'):
264             ids = [ids]
265         if ids == [uid]:
266             for key in values.keys():
267                 if not (key in self.SELF_WRITEABLE_FIELDS or key.startswith('context_')):
268                     break
269             else:
270                 if 'company_id' in values:
271                     if not (values['company_id'] in self.read(cr, 1, uid, ['company_ids'], context=context)['company_ids']):
272                         del values['company_id']
273                 uid = 1 # safe fields only, so we write as super-user to bypass access rights
274
275         res = super(users, self).write(cr, uid, ids, values, context=context)
276
277         # clear caches linked to the users
278         self.pool.get('ir.model.access').call_cache_clearing_methods(cr)
279         clear = partial(self.pool.get('ir.rule').clear_cache, cr)
280         map(clear, ids)
281         db = cr.dbname
282         if db in self._uid_cache:
283             for id in ids:
284                 if id in self._uid_cache[db]:
285                     del self._uid_cache[db][id]
286
287         return res
288
289     def unlink(self, cr, uid, ids, context=None):
290         if 1 in ids:
291             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, ...)'))
292         db = cr.dbname
293         if db in self._uid_cache:
294             for id in ids:
295                 if id in self._uid_cache[db]:
296                     del self._uid_cache[db][id]
297         return super(users, self).unlink(cr, uid, ids, context=context)
298
299     def name_search(self, cr, user, name='', args=None, operator='ilike', context=None, limit=100):
300         if not args:
301             args=[]
302         if not context:
303             context={}
304         ids = []
305         if name:
306             ids = self.search(cr, user, [('login','=',name)]+ args, limit=limit)
307         if not ids:
308             ids = self.search(cr, user, [('name',operator,name)]+ args, limit=limit)
309         return self.name_get(cr, user, ids)
310
311     def copy(self, cr, uid, id, default=None, context=None):
312         user2copy = self.read(cr, uid, [id], ['login','name'])[0]
313         if default is None:
314             default = {}
315         copy_pattern = _("%s (copy)")
316         copydef = dict(login=(copy_pattern % user2copy['login']),
317                        name=(copy_pattern % user2copy['name']),
318                        )
319         copydef.update(default)
320         return super(users, self).copy(cr, uid, id, copydef, context)
321
322     def context_get(self, cr, uid, context=None):
323         user = self.browse(cr, uid, uid, context)
324         result = {}
325         for k in self._all_columns.keys():
326             if k.startswith('context_'):
327                 context_key = k[8:]
328             elif k in ['lang', 'tz']:
329                 context_key = k
330             else:
331                 context_key = False
332             if context_key:
333                 res = getattr(user,k) or False
334                 if isinstance(res, browse_record):
335                     res = res.id
336                 result[context_key] = res or False
337         return result
338
339     def action_get(self, cr, uid, context=None):
340         dataobj = self.pool.get('ir.model.data')
341         data_id = dataobj._get_id(cr, 1, 'base', 'action_res_users_my')
342         return dataobj.browse(cr, uid, data_id, context=context).res_id
343
344     def authenticate(self, db, login, password, user_agent_env):
345         """Verifies and returns the user ID corresponding to the given
346           ``login`` and ``password`` combination, or False if there was
347           no matching user.
348
349            :param str db: the database on which user is trying to authenticate
350            :param str login: username
351            :param str password: user password
352            :param dict user_agent_env: environment dictionary describing any
353                relevant environment attributes
354         """
355         uid = self.login(db, login, password)
356         if uid == openerp.SUPERUSER_ID:
357             # Successfully logged in as admin!
358             # Attempt to guess the web base url...
359             if user_agent_env and user_agent_env.get('base_location'):
360                 cr = pooler.get_db(db).cursor()
361                 try:
362                     self.pool.get('ir.config_parameter').set_param(cr, uid, 'web.base.url',
363                                                                    user_agent_env['base_location'])
364                     cr.commit()
365                 except Exception:
366                     _logger.exception("Failed to update web.base.url configuration parameter")
367                 finally:
368                     cr.close()
369         return uid
370
371     def login(self, db, login, password):
372         if not password:
373             return False
374         cr = pooler.get_db(db).cursor()
375         try:
376             # autocommit: our single request will be performed atomically.
377             # (In this way, there is no opportunity to have two transactions
378             # interleaving their cr.execute()..cr.commit() calls and have one
379             # of them rolled back due to a concurrent access.)
380             # We effectively unconditionally write the res_users line.
381             cr.autocommit(True)
382             # Even w/ autocommit there's a chance the user row will be locked,
383             # in which case we can't delay the login just for the purpose of
384             # update the last login date - hence we use FOR UPDATE NOWAIT to
385             # try to get the lock - fail-fast
386             cr.execute("""SELECT id from res_users
387                           WHERE login=%s AND password=%s
388                                 AND active FOR UPDATE NOWAIT""",
389                        (tools.ustr(login), tools.ustr(password)))
390             cr.execute("""UPDATE res_users
391                             SET login_date = now() AT TIME ZONE 'UTC'
392                             WHERE login=%s AND password=%s AND active
393                             RETURNING id""",
394                        (tools.ustr(login), tools.ustr(password)))
395         except Exception:
396             # Failing to acquire the lock on the res_users row probably means
397             # another request is holding it. No big deal, we don't want to
398             # prevent/delay login in that case. It will also have been logged
399             # as a SQL error, if anyone cares.
400             cr.execute("""SELECT id from res_users
401                           WHERE login=%s AND password=%s
402                                 AND active""",
403                        (tools.ustr(login), tools.ustr(password)))
404         finally:
405             res = cr.fetchone()
406             cr.close()
407             if res:
408                 return res[0]
409         return False
410
411     def check_super(self, passwd):
412         if passwd == tools.config['admin_passwd']:
413             return True
414         else:
415             raise openerp.exceptions.AccessDenied()
416
417     def check(self, db, uid, passwd):
418         """Verifies that the given (uid, password) pair is authorized for the database ``db`` and
419            raise an exception if it is not."""
420         if not passwd:
421             # empty passwords disallowed for obvious security reasons
422             raise openerp.exceptions.AccessDenied()
423         if self._uid_cache.get(db, {}).get(uid) == passwd:
424             return
425         cr = pooler.get_db(db).cursor()
426         try:
427             cr.execute('SELECT COUNT(1) FROM res_users WHERE id=%s AND password=%s AND active=%s',
428                         (int(uid), passwd, True))
429             res = cr.fetchone()[0]
430             if not res:
431                 raise openerp.exceptions.AccessDenied()
432             if self._uid_cache.has_key(db):
433                 ulist = self._uid_cache[db]
434                 ulist[uid] = passwd
435             else:
436                 self._uid_cache[db] = {uid:passwd}
437         finally:
438             cr.close()
439
440     def access(self, db, uid, passwd, sec_level, ids):
441         if not passwd:
442             return False
443         cr = pooler.get_db(db).cursor()
444         try:
445             cr.execute('SELECT id FROM res_users WHERE id=%s AND password=%s', (uid, passwd))
446             res = cr.fetchone()
447             if not res:
448                 raise openerp.exceptions.AccessDenied()
449             return res[0]
450         finally:
451             cr.close()
452
453     def change_password(self, cr, uid, old_passwd, new_passwd, context=None):
454         """Change current user password. Old password must be provided explicitly
455         to prevent hijacking an existing user session, or for cases where the cleartext
456         password is not used to authenticate requests.
457
458         :return: True
459         :raise: openerp.exceptions.AccessDenied when old password is wrong
460         :raise: except_osv when new password is not set or empty
461         """
462         self.check(cr.dbname, uid, old_passwd)
463         if new_passwd:
464             return self.write(cr, uid, uid, {'password': new_passwd})
465         raise osv.except_osv(_('Warning!'), _("Setting empty passwords is not allowed for security reasons!"))
466
467     def preference_save(self, cr, uid, ids, context=None):
468         return {
469             'type': 'ir.actions.client',
470             'tag': 'reload',
471         }
472
473     def preference_change_password(self, cr, uid, ids, context=None):
474         return {
475             'type': 'ir.actions.client',
476             'tag': 'change_password',
477             'target': 'new',
478         }
479
480     def has_group(self, cr, uid, group_ext_id):
481         """Checks whether user belongs to given group.
482
483         :param str group_ext_id: external ID (XML ID) of the group.
484            Must be provided in fully-qualified form (``module.ext_id``), as there
485            is no implicit module to use..
486         :return: True if the current user is a member of the group with the
487            given external ID (XML ID), else False.
488         """
489         assert group_ext_id and '.' in group_ext_id, "External ID must be fully qualified"
490         module, ext_id = group_ext_id.split('.')
491         cr.execute("""SELECT 1 FROM res_groups_users_rel WHERE uid=%s AND gid IN 
492                         (SELECT res_id FROM ir_model_data WHERE module=%s AND name=%s)""",
493                    (uid, module, ext_id))
494         return bool(cr.fetchone())
495
496
497 #
498 # Extension of res.groups and res.users with a relation for "implied" or 
499 # "inherited" groups.  Once a user belongs to a group, it automatically belongs
500 # to the implied groups (transitively).
501 #
502
503 class cset(object):
504     """ A cset (constrained set) is a set of elements that may be constrained to
505         be a subset of other csets.  Elements added to a cset are automatically
506         added to its supersets.  Cycles in the subset constraints are supported.
507     """
508     def __init__(self, xs):
509         self.supersets = set()
510         self.elements = set(xs)
511     def subsetof(self, other):
512         if other is not self:
513             self.supersets.add(other)
514             other.update(self.elements)
515     def update(self, xs):
516         xs = set(xs) - self.elements
517         if xs:      # xs will eventually be empty in case of a cycle
518             self.elements.update(xs)
519             for s in self.supersets:
520                 s.update(xs)
521     def __iter__(self):
522         return iter(self.elements)
523
524 def concat(ls):
525     """ return the concatenation of a list of iterables """
526     res = []
527     for l in ls: res.extend(l)
528     return res
529
530
531
532 class groups_implied(osv.osv):
533     _inherit = 'res.groups'
534
535     def _get_trans_implied(self, cr, uid, ids, field, arg, context=None):
536         "computes the transitive closure of relation implied_ids"
537         memo = {}           # use a memo for performance and cycle avoidance
538         def computed_set(g):
539             if g not in memo:
540                 memo[g] = cset(g.implied_ids)
541                 for h in g.implied_ids:
542                     computed_set(h).subsetof(memo[g])
543             return memo[g]
544
545         res = {}
546         for g in self.browse(cr, 1, ids, context):
547             res[g.id] = map(int, computed_set(g))
548         return res
549
550     _columns = {
551         'implied_ids': fields.many2many('res.groups', 'res_groups_implied_rel', 'gid', 'hid',
552             string='Inherits', help='Users of this group automatically inherit those groups'),
553         'trans_implied_ids': fields.function(_get_trans_implied,
554             type='many2many', relation='res.groups', string='Transitively inherits'),
555     }
556
557     def create(self, cr, uid, values, context=None):
558         users = values.pop('users', None)
559         gid = super(groups_implied, self).create(cr, uid, values, context)
560         if users:
561             # delegate addition of users to add implied groups
562             self.write(cr, uid, [gid], {'users': users}, context)
563         return gid
564
565     def write(self, cr, uid, ids, values, context=None):
566         res = super(groups_implied, self).write(cr, uid, ids, values, context)
567         if values.get('users') or values.get('implied_ids'):
568             # add all implied groups (to all users of each group)
569             for g in self.browse(cr, uid, ids):
570                 gids = map(int, g.trans_implied_ids)
571                 vals = {'users': [(4, u.id) for u in g.users]}
572                 super(groups_implied, self).write(cr, uid, gids, vals, context)
573         return res
574
575 groups_implied()
576
577 class users_implied(osv.osv):
578     _inherit = 'res.users'
579
580     def create(self, cr, uid, values, context=None):
581         groups = values.pop('groups_id', None)
582         user_id = super(users_implied, self).create(cr, uid, values, context)
583         if groups:
584             # delegate addition of groups to add implied groups
585             self.write(cr, uid, [user_id], {'groups_id': groups}, context)
586         return user_id
587
588     def write(self, cr, uid, ids, values, context=None):
589         if not isinstance(ids,list):
590             ids = [ids]
591         res = super(users_implied, self).write(cr, uid, ids, values, context)
592         if values.get('groups_id'):
593             # add implied groups for all users
594             for user in self.browse(cr, uid, ids):
595                 gs = set(concat([g.trans_implied_ids for g in user.groups_id]))
596                 vals = {'groups_id': [(4, g.id) for g in gs]}
597                 super(users_implied, self).write(cr, uid, [user.id], vals, context)
598         return res
599
600 users_implied()
601
602
603
604 #
605 # Extension of res.groups and res.users for the special groups view in the users
606 # form.  This extension presents groups with selection and boolean widgets:
607 # - Groups are shown by application, with boolean and/or selection fields.
608 #   Selection fields typically defines a role "Name" for the given application.
609 # - Uncategorized groups are presented as boolean fields and grouped in a
610 #   section "Others".
611 #
612 # The user form view is modified by an inherited view (base.user_groups_view);
613 # the inherited view replaces the field 'groups_id' by a set of reified group
614 # fields (boolean or selection fields).  The arch of that view is regenerated
615 # each time groups are changed.
616 #
617 # Naming conventions for reified groups fields:
618 # - boolean field 'in_group_ID' is True iff
619 #       ID is in 'groups_id'
620 # - boolean field 'in_groups_ID1_..._IDk' is True iff
621 #       any of ID1, ..., IDk is in 'groups_id'
622 # - selection field 'sel_groups_ID1_..._IDk' is ID iff
623 #       ID is in 'groups_id' and ID is maximal in the set {ID1, ..., IDk}
624
625 def name_boolean_group(id): return 'in_group_' + str(id)
626 def name_boolean_groups(ids): return 'in_groups_' + '_'.join(map(str, ids))
627 def name_selection_groups(ids): return 'sel_groups_' + '_'.join(map(str, ids))
628
629 def is_boolean_group(name): return name.startswith('in_group_')
630 def is_boolean_groups(name): return name.startswith('in_groups_')
631 def is_selection_groups(name): return name.startswith('sel_groups_')
632 def is_reified_group(name):
633     return is_boolean_group(name) or is_boolean_groups(name) or is_selection_groups(name)
634
635 def get_boolean_group(name): return int(name[9:])
636 def get_boolean_groups(name): return map(int, name[10:].split('_'))
637 def get_selection_groups(name): return map(int, name[11:].split('_'))
638
639 def partition(f, xs):
640     "return a pair equivalent to (filter(f, xs), filter(lambda x: not f(x), xs))"
641     yes, nos = [], []
642     for x in xs:
643         (yes if f(x) else nos).append(x)
644     return yes, nos
645
646
647
648 class groups_view(osv.osv):
649     _inherit = 'res.groups'
650
651     def create(self, cr, uid, values, context=None):
652         res = super(groups_view, self).create(cr, uid, values, context)
653         self.update_user_groups_view(cr, uid, context)
654         return res
655
656     def write(self, cr, uid, ids, values, context=None):
657         res = super(groups_view, self).write(cr, uid, ids, values, context)
658         self.update_user_groups_view(cr, uid, context)
659         return res
660
661     def unlink(self, cr, uid, ids, context=None):
662         res = super(groups_view, self).unlink(cr, uid, ids, context)
663         self.update_user_groups_view(cr, uid, context)
664         return res
665
666     def update_user_groups_view(self, cr, uid, context=None):
667         # the view with id 'base.user_groups_view' inherits the user form view,
668         # and introduces the reified group fields
669         view = self.get_user_groups_view(cr, uid, context)
670         if view:
671             xml1, xml2 = [], []
672             xml1.append(E.separator(string=_('Application'), colspan="4"))
673             for app, kind, gs in self.get_groups_by_application(cr, uid, context):
674                 # hide groups in category 'Hidden' (except to group_no_one)
675                 attrs = {'groups': 'base.group_no_one'} if app and app.xml_id == 'base.module_category_hidden' else {}
676                 if kind == 'selection':
677                     # application name with a selection field
678                     field_name = name_selection_groups(map(int, gs))
679                     xml1.append(E.field(name=field_name, **attrs))
680                     xml1.append(E.newline())
681                 else:
682                     # application separator with boolean fields
683                     app_name = app and app.name or _('Other')
684                     xml2.append(E.separator(string=app_name, colspan="4", **attrs))
685                     for g in gs:
686                         field_name = name_boolean_group(g.id)
687                         xml2.append(E.field(name=field_name, **attrs))
688
689             xml = E.field(*(xml1 + xml2), name="groups_id", position="replace")
690             xml.addprevious(etree.Comment("GENERATED AUTOMATICALLY BY GROUPS"))
691             xml_content = etree.tostring(xml, pretty_print=True, xml_declaration=True, encoding="utf-8")
692             view.write({'arch': xml_content})
693         return True
694
695     def get_user_groups_view(self, cr, uid, context=None):
696         try:
697             view = self.pool.get('ir.model.data').get_object(cr, 1, 'base', 'user_groups_view', context)
698             assert view and view._table_name == 'ir.ui.view'
699         except Exception:
700             view = False
701         return view
702
703     def get_application_groups(self, cr, uid, domain=None, context=None):
704         return self.search(cr, uid, domain or [])
705
706     def get_groups_by_application(self, cr, uid, context=None):
707         """ return all groups classified by application (module category), as a list of pairs:
708                 [(app, kind, [group, ...]), ...],
709             where app and group are browse records, and kind is either 'boolean' or 'selection'.
710             Applications are given in sequence order.  If kind is 'selection', the groups are
711             given in reverse implication order.
712         """
713         def linearized(gs):
714             gs = set(gs)
715             # determine sequence order: a group should appear after its implied groups
716             order = dict.fromkeys(gs, 0)
717             for g in gs:
718                 for h in gs.intersection(g.trans_implied_ids):
719                     order[h] -= 1
720             # check whether order is total, i.e., sequence orders are distinct
721             if len(set(order.itervalues())) == len(gs):
722                 return sorted(gs, key=lambda g: order[g])
723             return None
724
725         # classify all groups by application
726         gids = self.get_application_groups(cr, uid, context=context)
727         by_app, others = {}, []
728         for g in self.browse(cr, uid, gids, context):
729             if g.category_id:
730                 by_app.setdefault(g.category_id, []).append(g)
731             else:
732                 others.append(g)
733         # build the result
734         res = []
735         apps = sorted(by_app.iterkeys(), key=lambda a: a.sequence or 0)
736         for app in apps:
737             gs = linearized(by_app[app])
738             if gs:
739                 res.append((app, 'selection', gs))
740             else:
741                 res.append((app, 'boolean', by_app[app]))
742         if others:
743             res.append((False, 'boolean', others))
744         return res
745
746 groups_view()
747
748 class users_view(osv.osv):
749     _inherit = 'res.users'
750
751     def create(self, cr, uid, values, context=None):
752         self._set_reified_groups(values)
753         return super(users_view, self).create(cr, uid, values, context)
754
755     def write(self, cr, uid, ids, values, context=None):
756         self._set_reified_groups(values)
757         return super(users_view, self).write(cr, uid, ids, values, context)
758
759     def _set_reified_groups(self, values):
760         """ reflect reified group fields in values['groups_id'] """
761         if 'groups_id' in values:
762             # groups are already given, ignore group fields
763             for f in filter(is_reified_group, values.iterkeys()):
764                 del values[f]
765             return
766
767         add, remove = [], []
768         for f in values.keys():
769             if is_boolean_group(f):
770                 target = add if values.pop(f) else remove
771                 target.append(get_boolean_group(f))
772             elif is_boolean_groups(f):
773                 if not values.pop(f):
774                     remove.extend(get_boolean_groups(f))
775             elif is_selection_groups(f):
776                 remove.extend(get_selection_groups(f))
777                 selected = values.pop(f)
778                 if selected:
779                     add.append(selected)
780         # update values *only* if groups are being modified, otherwise
781         # we introduce spurious changes that might break the super.write() call.
782         if add or remove:
783             # remove groups in 'remove' and add groups in 'add'
784             values['groups_id'] = [(3, id) for id in remove] + [(4, id) for id in add]
785
786     def default_get(self, cr, uid, fields, context=None):
787         group_fields, fields = partition(is_reified_group, fields)
788         fields1 = (fields + ['groups_id']) if group_fields else fields
789         values = super(users_view, self).default_get(cr, uid, fields1, context)
790         self._get_reified_groups(group_fields, values)
791         return values
792
793     def read(self, cr, uid, ids, fields=None, context=None, load='_classic_read'):
794         if not fields:
795             fields = self.fields_get(cr, uid, context=context).keys()
796         group_fields, fields = partition(is_reified_group, fields)
797         if not 'groups_id' in fields:
798             fields.append('groups_id')
799         res = super(users_view, self).read(cr, uid, ids, fields, context=context, load=load)
800         for values in (res if isinstance(res, list) else [res]):
801             self._get_reified_groups(group_fields, values)
802         return res
803
804     def _get_reified_groups(self, fields, values):
805         """ compute the given reified group fields from values['groups_id'] """
806         gids = set(values.get('groups_id') or [])
807         for f in fields:
808             if is_boolean_group(f):
809                 values[f] = get_boolean_group(f) in gids
810             elif is_boolean_groups(f):
811                 values[f] = not gids.isdisjoint(get_boolean_groups(f))
812             elif is_selection_groups(f):
813                 selected = [gid for gid in get_selection_groups(f) if gid in gids]
814                 values[f] = selected and selected[-1] or False
815
816     def fields_get(self, cr, uid, allfields=None, context=None, write_access=True):
817         res = super(users_view, self).fields_get(cr, uid, allfields, context, write_access)
818         # add reified groups fields
819         for app, kind, gs in self.pool.get('res.groups').get_groups_by_application(cr, uid, context):
820             if kind == 'selection':
821                 # selection group field
822                 tips = ['%s: %s' % (g.name, g.comment or '') for g in gs]
823                 res[name_selection_groups(map(int, gs))] = {
824                     'type': 'selection',
825                     'string': app and app.name or _('Other'),
826                     'selection': [(False, '')] + [(g.id, g.name) for g in gs],
827                     'help': '\n'.join(tips),
828                 }
829             else:
830                 # boolean group fields
831                 for g in gs:
832                     res[name_boolean_group(g.id)] = {
833                         'type': 'boolean',
834                         'string': g.name,
835                         'help': g.comment,
836                     }
837         return res
838
839 users_view()
840
841 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: