summaryrefslogtreecommitdiff
path: root/odoo/addons/base/models
diff options
context:
space:
mode:
authorThibault Delavallée <tde@odoo.com>2017-10-20 11:39:28 +0200
committerThibault Delavallée <tde@odoo.com>2017-11-27 11:15:00 +0100
commitc129b91b6dab7a236facf8a6eb28e2aecd6ee865 (patch)
treecf14319e7b0f2805f4ed115768743634431514fb /odoo/addons/base/models
parentee5d46ceb16cd05fb05c834bd742866c29968171 (diff)
[MOV] base: move res_* models into models/
Diffstat (limited to 'odoo/addons/base/models')
-rw-r--r--odoo/addons/base/models/__init__.py12
-rw-r--r--odoo/addons/base/models/report_paperformat.py68
-rw-r--r--odoo/addons/base/models/res_bank.py102
-rw-r--r--odoo/addons/base/models/res_company.py261
-rw-r--r--odoo/addons/base/models/res_config.py671
-rw-r--r--odoo/addons/base/models/res_country.py131
-rw-r--r--odoo/addons/base/models/res_currency.py251
-rw-r--r--odoo/addons/base/models/res_lang.py317
-rw-r--r--odoo/addons/base/models/res_partner.py823
-rw-r--r--odoo/addons/base/models/res_users.py957
10 files changed, 3593 insertions, 0 deletions
diff --git a/odoo/addons/base/models/__init__.py b/odoo/addons/base/models/__init__.py
new file mode 100644
index 00000000000..0ecce19cda7
--- /dev/null
+++ b/odoo/addons/base/models/__init__.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import report_paperformat
+from . import res_country
+from . import res_lang
+from . import res_partner
+from . import res_bank
+from . import res_config
+from . import res_currency
+from . import res_company
+from . import res_users
diff --git a/odoo/addons/base/models/report_paperformat.py b/odoo/addons/base/models/report_paperformat.py
new file mode 100644
index 00000000000..08235f3060b
--- /dev/null
+++ b/odoo/addons/base/models/report_paperformat.py
@@ -0,0 +1,68 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, _
+from odoo.exceptions import ValidationError
+
+class report_paperformat(models.Model):
+ _name = "report.paperformat"
+ _description = "Allows customization of a report."
+
+ name = fields.Char('Name', required=True)
+ default = fields.Boolean('Default paper format ?')
+ format = fields.Selection([
+ ('A0', 'A0 5 841 x 1189 mm'),
+ ('A1', 'A1 6 594 x 841 mm'),
+ ('A2', 'A2 7 420 x 594 mm'),
+ ('A3', 'A3 8 297 x 420 mm'),
+ ('A4', 'A4 0 210 x 297 mm, 8.26 x 11.69 inches'),
+ ('A5', 'A5 9 148 x 210 mm'),
+ ('A6', 'A6 10 105 x 148 mm'),
+ ('A7', 'A7 11 74 x 105 mm'),
+ ('A8', 'A8 12 52 x 74 mm'),
+ ('A9', 'A9 13 37 x 52 mm'),
+ ('B0', 'B0 14 1000 x 1414 mm'),
+ ('B1', 'B1 15 707 x 1000 mm'),
+ ('B2', 'B2 17 500 x 707 mm'),
+ ('B3', 'B3 18 353 x 500 mm'),
+ ('B4', 'B4 19 250 x 353 mm'),
+ ('B5', 'B5 1 176 x 250 mm, 6.93 x 9.84 inches'),
+ ('B6', 'B6 20 125 x 176 mm'),
+ ('B7', 'B7 21 88 x 125 mm'),
+ ('B8', 'B8 22 62 x 88 mm'),
+ ('B9', 'B9 23 33 x 62 mm'),
+ ('B10', ':B10 16 31 x 44 mm'),
+ ('C5E', 'C5E 24 163 x 229 mm'),
+ ('Comm10E', 'Comm10E 25 105 x 241 mm, U.S. '
+ 'Common 10 Envelope'),
+ ('DLE', 'DLE 26 110 x 220 mm'),
+ ('Executive', 'Executive 4 7.5 x 10 inches, '
+ '190.5 x 254 mm'),
+ ('Folio', 'Folio 27 210 x 330 mm'),
+ ('Ledger', 'Ledger 28 431.8 x 279.4 mm'),
+ ('Legal', 'Legal 3 8.5 x 14 inches, '
+ '215.9 x 355.6 mm'),
+ ('Letter', 'Letter 2 8.5 x 11 inches, '
+ '215.9 x 279.4 mm'),
+ ('Tabloid', 'Tabloid 29 279.4 x 431.8 mm'),
+ ('custom', 'Custom')
+ ], 'Paper size', default='A4', help="Select Proper Paper size")
+ margin_top = fields.Float('Top Margin (mm)', default=40)
+ margin_bottom = fields.Float('Bottom Margin (mm)', default=20)
+ margin_left = fields.Float('Left Margin (mm)', default=7)
+ margin_right = fields.Float('Right Margin (mm)', default=7)
+ page_height = fields.Integer('Page height (mm)', default=False)
+ page_width = fields.Integer('Page width (mm)', default=False)
+ orientation = fields.Selection([
+ ('Landscape', 'Landscape'),
+ ('Portrait', 'Portrait')
+ ], 'Orientation', default='Landscape')
+ header_line = fields.Boolean('Display a header line', default=False)
+ header_spacing = fields.Integer('Header spacing', default=35)
+ dpi = fields.Integer('Output DPI', required=True, default=90)
+ report_ids = fields.One2many('ir.actions.report', 'paperformat_id', 'Associated reports', help="Explicitly associated reports")
+
+ @api.constrains('format')
+ def _check_format_or_page(self):
+ if self.filtered(lambda x: x.format != 'custom' and (x.page_width or x.page_height)):
+ raise ValidationError(_('Error ! You cannot select a format AND specific page width/height.'))
diff --git a/odoo/addons/base/models/res_bank.py b/odoo/addons/base/models/res_bank.py
new file mode 100644
index 00000000000..5a397fcc831
--- /dev/null
+++ b/odoo/addons/base/models/res_bank.py
@@ -0,0 +1,102 @@
+# -*- coding: utf-8 -*-
+
+import re
+
+import collections
+
+from odoo import api, fields, models
+from odoo.osv import expression
+from odoo.tools import pycompat
+
+
+def sanitize_account_number(acc_number):
+ if acc_number:
+ return re.sub(r'\W+', '', acc_number).upper()
+ return False
+
+
+class Bank(models.Model):
+ _description = 'Bank'
+ _name = 'res.bank'
+ _order = 'name'
+
+ name = fields.Char(required=True)
+ street = fields.Char()
+ street2 = fields.Char()
+ zip = fields.Char()
+ city = fields.Char()
+ state = fields.Many2one('res.country.state', 'Fed. State', domain="[('country_id', '=', country)]")
+ country = fields.Many2one('res.country')
+ email = fields.Char()
+ phone = fields.Char()
+ active = fields.Boolean(default=True)
+ bic = fields.Char('Bank Identifier Code', index=True, help="Sometimes called BIC or Swift.")
+
+ @api.multi
+ @api.depends('name', 'bic')
+ def name_get(self):
+ result = []
+ for bank in self:
+ name = bank.name + (bank.bic and (' - ' + bank.bic) or '')
+ result.append((bank.id, name))
+ return result
+
+ @api.model
+ def name_search(self, name, args=None, operator='ilike', limit=100):
+ args = args or []
+ domain = []
+ if name:
+ domain = ['|', ('bic', '=ilike', name + '%'), ('name', operator, name)]
+ if operator in expression.NEGATIVE_TERM_OPERATORS:
+ domain = ['&'] + domain
+ banks = self.search(domain + args, limit=limit)
+ return banks.name_get()
+
+
+class ResPartnerBank(models.Model):
+ _name = 'res.partner.bank'
+ _rec_name = 'acc_number'
+ _description = 'Bank Accounts'
+ _order = 'sequence'
+
+ acc_type = fields.Char(compute='_compute_acc_type', help='Bank account type, inferred from account number')
+ acc_number = fields.Char('Account Number', required=True)
+ sanitized_acc_number = fields.Char(compute='_compute_sanitized_acc_number', string='Sanitized Account Number', readonly=True, store=True)
+ partner_id = fields.Many2one('res.partner', 'Account Holder', ondelete='cascade', index=True, domain=['|', ('is_company', '=', True), ('parent_id', '=', False)], default=lambda self: self.env.user.company_id.partner_id)
+ bank_id = fields.Many2one('res.bank', string='Bank')
+ bank_name = fields.Char(related='bank_id.name')
+ bank_bic = fields.Char(related='bank_id.bic')
+ sequence = fields.Integer()
+ currency_id = fields.Many2one('res.currency', string='Currency')
+ company_id = fields.Many2one('res.company', 'Company', default=lambda self: self.env.user.company_id, ondelete='cascade')
+
+ _sql_constraints = [
+ ('unique_number', 'unique(sanitized_acc_number, company_id)', 'Account Number must be unique'),
+ ]
+
+ @api.depends('acc_number')
+ def _compute_sanitized_acc_number(self):
+ for bank in self:
+ bank.sanitized_acc_number = sanitize_account_number(bank.acc_number)
+
+ @api.multi
+ def _compute_acc_type(self):
+ for bank in self:
+ bank.acc_type = 'bank'
+
+ @api.model
+ def search(self, args, offset=0, limit=None, order=None, count=False):
+ pos = 0
+ while pos < len(args):
+ if args[pos][0] == 'acc_number':
+ op = args[pos][1]
+ value = args[pos][2]
+ if not isinstance(value, pycompat.string_types) and isinstance(value, collections.Iterable):
+ value = [sanitize_account_number(i) for i in value]
+ else:
+ value = sanitize_account_number(value)
+ if 'like' in op:
+ value = '%' + value + '%'
+ args[pos] = ('sanitized_acc_number', op, value)
+ pos += 1
+ return super(ResPartnerBank, self).search(args, offset, limit, order, count=count)
diff --git a/odoo/addons/base/models/res_company.py b/odoo/addons/base/models/res_company.py
new file mode 100644
index 00000000000..b95b502f6a3
--- /dev/null
+++ b/odoo/addons/base/models/res_company.py
@@ -0,0 +1,261 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import base64
+import os
+import re
+
+from odoo import api, fields, models, tools, _
+from odoo.exceptions import ValidationError
+
+
+class Company(models.Model):
+ _name = "res.company"
+ _description = 'Companies'
+ _order = 'sequence, name'
+
+ @api.multi
+ def copy(self, default=None):
+ """
+ Duplicating a company without specifying a partner duplicate the partner
+ """
+ self.ensure_one()
+ default = dict(default or {})
+ if not default.get('name') and not default.get('partner_id'):
+ copy_partner = self.partner_id.copy()
+ default['partner_id'] = copy_partner.id
+ default['name'] = copy_partner.name
+ return super(Company, self).copy(default)
+
+ def _get_logo(self):
+ return base64.b64encode(open(os.path.join(tools.config['root_path'], 'addons', 'base', 'static', 'img', 'res_company_logo.png'), 'rb') .read())
+
+ @api.model
+ def _get_euro(self):
+ return self.env['res.currency.rate'].search([('rate', '=', 1)], limit=1).currency_id
+
+ @api.model
+ def _get_user_currency(self):
+ currency_id = self.env['res.users'].browse(self._uid).company_id.currency_id
+ return currency_id or self._get_euro()
+
+ name = fields.Char(related='partner_id.name', string='Company Name', required=True, store=True)
+ sequence = fields.Integer(help='Used to order Companies in the company switcher', default=10)
+ parent_id = fields.Many2one('res.company', string='Parent Company', index=True)
+ child_ids = fields.One2many('res.company', 'parent_id', string='Child Companies')
+ partner_id = fields.Many2one('res.partner', string='Partner', required=True)
+ report_header = fields.Text(string='Company Tagline', help="Appears by default on the top right corner of your printed documents (report header).")
+ report_footer = fields.Text(string='Report Footer', translate=True, help="Footer text displayed at the bottom of all reports.")
+ logo = fields.Binary(related='partner_id.image', default=_get_logo, string="Company Logo")
+ # logo_web: do not store in attachments, since the image is retrieved in SQL for
+ # performance reasons (see addons/web/controllers/main.py, Binary.company_logo)
+ logo_web = fields.Binary(compute='_compute_logo_web', store=True)
+ currency_id = fields.Many2one('res.currency', string='Currency', required=True, default=lambda self: self._get_user_currency())
+ user_ids = fields.Many2many('res.users', 'res_company_users_rel', 'cid', 'user_id', string='Accepted Users')
+ account_no = fields.Char(string='Account No.')
+ street = fields.Char(compute='_compute_address', inverse='_inverse_street')
+ street2 = fields.Char(compute='_compute_address', inverse='_inverse_street2')
+ zip = fields.Char(compute='_compute_address', inverse='_inverse_zip')
+ city = fields.Char(compute='_compute_address', inverse='_inverse_city')
+ state_id = fields.Many2one('res.country.state', compute='_compute_address', inverse='_inverse_state', string="Fed. State")
+ bank_ids = fields.One2many('res.partner.bank', 'company_id', string='Bank Accounts', help='Bank accounts related to this company')
+ country_id = fields.Many2one('res.country', compute='_compute_address', inverse='_inverse_country', string="Country")
+ email = fields.Char(related='partner_id.email', store=True)
+ phone = fields.Char(related='partner_id.phone', store=True)
+ website = fields.Char(related='partner_id.website')
+ vat = fields.Char(related='partner_id.vat', string="TIN")
+ company_registry = fields.Char()
+ paperformat_id = fields.Many2one('report.paperformat', 'Paper format', default=lambda self: self.env.ref('base.paperformat_euro', raise_if_not_found=False))
+ external_report_layout = fields.Selection([
+ ('background', 'Background'),
+ ('boxed', 'Boxed'),
+ ('clean', 'Clean'),
+ ('standard', 'Standard'),
+ ], string='Document Template')
+
+ _sql_constraints = [
+ ('name_uniq', 'unique (name)', 'The company name must be unique !')
+ ]
+
+ @api.model_cr
+ def init(self):
+ for company in self.search([('paperformat_id', '=', False)]):
+ paperformat_euro = self.env.ref('base.paperformat_euro', False)
+ if paperformat_euro:
+ company.write({'paperformat_id': paperformat_euro.id})
+ sup = super(Company, self)
+ if hasattr(sup, 'init'):
+ sup.init()
+
+ def _get_company_address_fields(self, partner):
+ return {
+ 'street' : partner.street,
+ 'street2' : partner.street2,
+ 'city' : partner.city,
+ 'zip' : partner.zip,
+ 'state_id' : partner.state_id,
+ 'country_id' : partner.country_id,
+ }
+
+ # TODO @api.depends(): currently now way to formulate the dependency on the
+ # partner's contact address
+ def _compute_address(self):
+ for company in self.filtered(lambda company: company.partner_id):
+ address_data = company.partner_id.sudo().address_get(adr_pref=['contact'])
+ if address_data['contact']:
+ partner = company.partner_id.browse(address_data['contact']).sudo()
+ company.update(company._get_company_address_fields(partner))
+
+ def _inverse_street(self):
+ for company in self:
+ company.partner_id.street = company.street
+
+ def _inverse_street2(self):
+ for company in self:
+ company.partner_id.street2 = company.street2
+
+ def _inverse_zip(self):
+ for company in self:
+ company.partner_id.zip = company.zip
+
+ def _inverse_city(self):
+ for company in self:
+ company.partner_id.city = company.city
+
+ def _inverse_state(self):
+ for company in self:
+ company.partner_id.state_id = company.state_id
+
+ def _inverse_country(self):
+ for company in self:
+ company.partner_id.country_id = company.country_id
+
+ @api.depends('partner_id', 'partner_id.image')
+ def _compute_logo_web(self):
+ for company in self:
+ company.logo_web = tools.image_resize_image(company.partner_id.image, (180, None))
+
+ @api.onchange('state_id')
+ def _onchange_state(self):
+ self.country_id = self.state_id.country_id
+
+ @api.multi
+ def on_change_country(self, country_id):
+ # This function is called from account/models/chart_template.py, hence decorated with `multi`.
+ self.ensure_one()
+ currency_id = self._get_user_currency()
+ if country_id:
+ currency_id = self.env['res.country'].browse(country_id).currency_id.id
+ return {'value': {'currency_id': currency_id}}
+
+ @api.onchange('country_id')
+ def _onchange_country_id_wrapper(self):
+ res = {'domain': {'state_id': []}}
+ if self.country_id:
+ res['domain']['state_id'] = [('country_id', '=', self.country_id.id)]
+ values = self.on_change_country(self.country_id.id)['value']
+ for fname, value in values.items():
+ setattr(self, fname, value)
+ return res
+
+ @api.model
+ def name_search(self, name='', args=None, operator='ilike', limit=100):
+ context = dict(self.env.context)
+ newself = self
+ if context.pop('user_preference', None):
+ # We browse as superuser. Otherwise, the user would be able to
+ # select only the currently visible companies (according to rules,
+ # which are probably to allow to see the child companies) even if
+ # she belongs to some other companies.
+ companies = self.env.user.company_id + self.env.user.company_ids
+ args = (args or []) + [('id', 'in', companies.ids)]
+ newself = newself.sudo()
+ return super(Company, newself.with_context(context)).name_search(name=name, args=args, operator=operator, limit=limit)
+
+ @api.model
+ @api.returns('self', lambda value: value.id)
+ def _company_default_get(self, object=False, field=False):
+ """ Returns the default company (usually the user's company).
+ The 'object' and 'field' arguments are ignored but left here for
+ backward compatibility and potential override.
+ """
+ return self.env['res.users']._get_company()
+
+ @api.model
+ @tools.ormcache('self.env.uid', 'company')
+ def _get_company_children(self, company=None):
+ if not company:
+ return []
+ return self.search([('parent_id', 'child_of', [company])]).ids
+
+ @api.multi
+ def _get_partner_hierarchy(self):
+ self.ensure_one()
+ parent = self.parent_id
+ if parent:
+ return parent._get_partner_hierarchy()
+ else:
+ return self._get_partner_descendance([])
+
+ @api.multi
+ def _get_partner_descendance(self, descendance):
+ self.ensure_one()
+ descendance.append(self.partner_id.id)
+ for child_id in self._get_company_children(self.id):
+ if child_id != self.id:
+ descendance = self.browse(child_id)._get_partner_descendance(descendance)
+ return descendance
+
+ # deprecated, use clear_caches() instead
+ def cache_restart(self):
+ self.clear_caches()
+
+ @api.model
+ def create(self, vals):
+ if not vals.get('name') or vals.get('partner_id'):
+ self.clear_caches()
+ return super(Company, self).create(vals)
+ partner = self.env['res.partner'].create({
+ 'name': vals['name'],
+ 'is_company': True,
+ 'image': vals.get('logo'),
+ 'customer': False,
+ 'email': vals.get('email'),
+ 'phone': vals.get('phone'),
+ 'website': vals.get('website'),
+ 'vat': vals.get('vat'),
+ })
+ vals['partner_id'] = partner.id
+ self.clear_caches()
+ company = super(Company, self).create(vals)
+ partner.write({'company_id': company.id})
+ return company
+
+ @api.multi
+ def write(self, values):
+ self.clear_caches()
+ return super(Company, self).write(values)
+
+ @api.constrains('parent_id')
+ def _check_parent_id(self):
+ if not self._check_recursion():
+ raise ValidationError(_('Error ! You cannot create recursive companies.'))
+
+ @api.multi
+ def open_company_edit_report(self):
+ self.ensure_one()
+ return self.env['res.config.settings'].open_company()
+
+ @api.multi
+ def write_company_and_print_report(self, values):
+ res = self.write(values)
+
+ report_name = values.get('default_report_name')
+ active_ids = values.get('active_ids')
+ active_model = values.get('active_model')
+ if report_name and active_ids and active_model:
+ docids = self.env[active_model].browse(active_ids)
+ return (self.env['ir.actions.report'].search([('report_name', '=', report_name)], limit=1)
+ .with_context(values)
+ .report_action(docids))
+ else:
+ return res
diff --git a/odoo/addons/base/models/res_config.py b/odoo/addons/base/models/res_config.py
new file mode 100644
index 00000000000..81e62bdf2d8
--- /dev/null
+++ b/odoo/addons/base/models/res_config.py
@@ -0,0 +1,671 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import json
+import logging
+import re
+
+from operator import attrgetter, add
+from lxml import etree
+
+from odoo import api, models, registry, SUPERUSER_ID, _
+from odoo.exceptions import AccessError, RedirectWarning, UserError
+from odoo.tools import ustr
+
+_logger = logging.getLogger(__name__)
+
+
+class ResConfigModuleInstallationMixin(object):
+ @api.model
+ def _install_modules(self, modules):
+ """ Install the requested modules.
+
+ :param modules: a list of tuples (module_name, module_record)
+ :return: the next action to execute
+ """
+ to_install_modules = self.env['ir.module.module']
+ to_install_missing_names = []
+
+ for name, module in modules:
+ if not module:
+ to_install_missing_names.append(name)
+ elif module.state == 'uninstalled':
+ to_install_modules += module
+ result = None
+ if to_install_modules:
+ result = to_install_modules.button_immediate_install()
+ #FIXME: if result is not none, the corresponding todo will be skipped because it was just marked done
+ if to_install_missing_names:
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'apps',
+ 'params': {'modules': to_install_missing_names},
+ }
+
+ return result
+
+
+class ResConfigConfigurable(models.TransientModel):
+ ''' Base classes for new-style configuration items
+
+ Configuration items should inherit from this class, implement
+ the execute method (and optionally the cancel one) and have
+ their view inherit from the related res_config_view_base view.
+ '''
+ _name = 'res.config'
+
+ @api.multi
+ def start(self):
+ # pylint: disable=next-method-called
+ return self.next()
+
+ @api.multi
+ def next(self):
+ """
+ Reload the settings page
+ """
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'reload',
+ }
+
+ @api.multi
+ def execute(self):
+ """ Method called when the user clicks on the ``Next`` button.
+
+ Execute *must* be overloaded unless ``action_next`` is overloaded
+ (which is something you generally don't need to do).
+
+ If ``execute`` returns an action dictionary, that action is executed
+ rather than just going to the next configuration item.
+ """
+ raise NotImplementedError(
+ 'Configuration items need to implement execute')
+
+ @api.multi
+ def cancel(self):
+ """ Method called when the user click on the ``Skip`` button.
+
+ ``cancel`` should be overloaded instead of ``action_skip``. As with
+ ``execute``, if it returns an action dictionary that action is
+ executed in stead of the default (going to the next configuration item)
+
+ The default implementation is a NOOP.
+
+ ``cancel`` is also called by the default implementation of
+ ``action_cancel``.
+ """
+ pass
+
+ @api.multi
+ def action_next(self):
+ """ Action handler for the ``next`` event.
+
+ Sets the status of the todo the event was sent from to
+ ``done``, calls ``execute`` and -- unless ``execute`` returned
+ an action dictionary -- executes the action provided by calling
+ ``next``.
+ """
+ # pylint: disable=next-method-called
+ return self.execute() or self.next()
+
+ @api.multi
+ def action_skip(self):
+ """ Action handler for the ``skip`` event.
+
+ Sets the status of the todo the event was sent from to
+ ``skip``, calls ``cancel`` and -- unless ``cancel`` returned
+ an action dictionary -- executes the action provided by calling
+ ``next``.
+ """
+ # pylint: disable=next-method-called
+ return self.cancel() or self.next()
+
+ @api.multi
+ def action_cancel(self):
+ """ Action handler for the ``cancel`` event. That event isn't
+ generated by the res.config.view.base inheritable view, the
+ inherited view has to overload one of the buttons (or add one
+ more).
+
+ Sets the status of the todo the event was sent from to
+ ``cancel``, calls ``cancel`` and -- unless ``cancel`` returned
+ an action dictionary -- executes the action provided by calling
+ ``next``.
+ """
+ # pylint: disable=next-method-called
+ return self.cancel() or self.next()
+
+
+class ResConfigInstaller(models.TransientModel, ResConfigModuleInstallationMixin):
+ """ New-style configuration base specialized for addons selection
+ and installation.
+
+ Basic usage
+ -----------
+
+ Subclasses can simply define a number of boolean fields. The field names
+ should be the names of the addons to install (when selected). Upon action
+ execution, selected boolean fields (and those only) will be interpreted as
+ addons to install, and batch-installed.
+
+ Additional addons
+ -----------------
+
+ It is also possible to require the installation of an additional
+ addon set when a specific preset of addons has been marked for
+ installation (in the basic usage only, additionals can't depend on
+ one another).
+
+ These additionals are defined through the ``_install_if``
+ property. This property is a mapping of a collection of addons (by
+ name) to a collection of addons (by name) [#]_, and if all the *key*
+ addons are selected for installation, then the *value* ones will
+ be selected as well. For example::
+
+ _install_if = {
+ ('sale','crm'): ['sale_crm'],
+ }
+
+ This will install the ``sale_crm`` addon if and only if both the
+ ``sale`` and ``crm`` addons are selected for installation.
+
+ You can define as many additionals as you wish, and additionals
+ can overlap in key and value. For instance::
+
+ _install_if = {
+ ('sale','crm'): ['sale_crm'],
+ ('sale','project'): ['sale_service'],
+ }
+
+ will install both ``sale_crm`` and ``sale_service`` if all of
+ ``sale``, ``crm`` and ``project`` are selected for installation.
+
+ Hook methods
+ ------------
+
+ Subclasses might also need to express dependencies more complex
+ than that provided by additionals. In this case, it's possible to
+ define methods of the form ``_if_%(name)s`` where ``name`` is the
+ name of a boolean field. If the field is selected, then the
+ corresponding module will be marked for installation *and* the
+ hook method will be executed.
+
+ Hook methods take the usual set of parameters (cr, uid, ids,
+ context) and can return a collection of additional addons to
+ install (if they return anything, otherwise they should not return
+ anything, though returning any "falsy" value such as None or an
+ empty collection will have the same effect).
+
+ Complete control
+ ----------------
+
+ The last hook is to simply overload the ``modules_to_install``
+ method, which implements all the mechanisms above. This method
+ takes the usual set of parameters (cr, uid, ids, context) and
+ returns a ``set`` of addons to install (addons selected by the
+ above methods minus addons from the *basic* set which are already
+ installed) [#]_ so an overloader can simply manipulate the ``set``
+ returned by ``ResConfigInstaller.modules_to_install`` to add or
+ remove addons.
+
+ Skipping the installer
+ ----------------------
+
+ Unless it is removed from the view, installers have a *skip*
+ button which invokes ``action_skip`` (and the ``cancel`` hook from
+ ``res.config``). Hooks and additionals *are not run* when skipping
+ installation, even for already installed addons.
+
+ Again, setup your hooks accordingly.
+
+ .. [#] note that since a mapping key needs to be hashable, it's
+ possible to use a tuple or a frozenset, but not a list or a
+ regular set
+
+ .. [#] because the already-installed modules are only pruned at
+ the very end of ``modules_to_install``, additionals and
+ hooks depending on them *are guaranteed to execute*. Setup
+ your hooks accordingly.
+ """
+ _name = 'res.config.installer'
+ _inherit = 'res.config'
+
+ _install_if = {}
+
+ def already_installed(self):
+ """ For each module, check if it's already installed and if it
+ is return its name
+
+ :returns: a list of the already installed modules in this
+ installer
+ :rtype: [str]
+ """
+ return [m.name for m in self._already_installed()]
+
+ def _already_installed(self):
+ """ For each module (boolean fields in a res.config.installer),
+ check if it's already installed (either 'to install', 'to upgrade'
+ or 'installed') and if it is return the module's record
+
+ :returns: a list of all installed modules in this installer
+ :rtype: recordset (collection of Record)
+ """
+ selectable = [name for name, field in self._fields.items()
+ if field.type == 'boolean']
+ return self.env['ir.module.module'].search([('name', 'in', selectable),
+ ('state', 'in', ['to install', 'installed', 'to upgrade'])])
+
+ def modules_to_install(self):
+ """ selects all modules to install:
+
+ * checked boolean fields
+ * return values of hook methods. Hook methods are of the form
+ ``_if_%(addon_name)s``, and are called if the corresponding
+ addon is marked for installation. They take the arguments
+ cr, uid, ids and context, and return an iterable of addon
+ names
+ * additionals, additionals are setup through the ``_install_if``
+ class variable. ``_install_if`` is a dict of {iterable:iterable}
+ where key and value are iterables of addon names.
+
+ If all the addons in the key are selected for installation
+ (warning: addons added through hooks don't count), then the
+ addons in the value are added to the set of modules to install
+ * not already installed
+ """
+ base = set(module_name
+ for installer in self.read()
+ for module_name, to_install in installer.items()
+ if self._fields[module_name].type == 'boolean' and to_install)
+
+ hooks_results = set()
+ for module in base:
+ hook = getattr(self, '_if_%s'% module, None)
+ if hook:
+ hooks_results.update(hook() or set())
+
+ additionals = set(module
+ for requirements, consequences in self._install_if.items()
+ if base.issuperset(requirements)
+ for module in consequences)
+
+ return (base | hooks_results | additionals) - set(self.already_installed())
+
+ @api.model
+ def default_get(self, fields_list):
+ ''' If an addon is already installed, check it by default
+ '''
+ defaults = super(ResConfigInstaller, self).default_get(fields_list)
+ return dict(defaults, **dict.fromkeys(self.already_installed(), True))
+
+ @api.model
+ def fields_get(self, fields=None, attributes=None):
+ """ If an addon is already installed, set it to readonly as
+ res.config.installer doesn't handle uninstallations of already
+ installed addons
+ """
+ fields = super(ResConfigInstaller, self).fields_get(fields, attributes=attributes)
+
+ for name in self.already_installed():
+ if name not in fields:
+ continue
+ fields[name].update(
+ readonly=True,
+ help= ustr(fields[name].get('help', '')) +
+ _('\n\nThis addon is already installed on your system'))
+ return fields
+
+ @api.multi
+ def execute(self):
+ to_install = list(self.modules_to_install())
+ _logger.info('Selecting addons %s to install', to_install)
+
+ IrModule = self.env['ir.module.module']
+ modules = []
+ for name in to_install:
+ module = IrModule.search([('name', '=', name)], limit=1)
+ modules.append((name, module))
+
+ return self._install_modules(modules)
+
+
+class ResConfigSettings(models.TransientModel, ResConfigModuleInstallationMixin):
+ """ Base configuration wizard for application settings. It provides support for setting
+ default values, assigning groups to employee users, and installing modules.
+ To make such a 'settings' wizard, define a model like::
+
+ class MyConfigWizard(models.TransientModel):
+ _name = 'my.settings'
+ _inherit = 'res.config.settings'
+
+ default_foo = fields.type(..., default_model='my.model'),
+ group_bar = fields.Boolean(..., group='base.group_user', implied_group='my.group'),
+ module_baz = fields.Boolean(...),
+ other_field = fields.type(...),
+
+ The method ``execute`` provides some support based on a naming convention:
+
+ * For a field like 'default_XXX', ``execute`` sets the (global) default value of
+ the field 'XXX' in the model named by ``default_model`` to the field's value.
+
+ * For a boolean field like 'group_XXX', ``execute`` adds/removes 'implied_group'
+ to/from the implied groups of 'group', depending on the field's value.
+ By default 'group' is the group Employee. Groups are given by their xml id.
+ The attribute 'group' may contain several xml ids, separated by commas.
+
+ * For a selection field like 'group_XXX' composed of 2 integers values ('0' and '1'),
+ ``execute`` adds/removes 'implied_group' to/from the implied groups of 'group',
+ depending on the field's value.
+ By default 'group' is the group Employee. Groups are given by their xml id.
+ The attribute 'group' may contain several xml ids, separated by commas.
+
+ * For a boolean field like 'module_XXX', ``execute`` triggers the immediate
+ installation of the module named 'XXX' if the field has value ``True``.
+
+ * For a selection field like 'module_XXX' composed of 2 integers values ('0' and '1'),
+ ``execute`` triggers the immediate installation of the module named 'XXX'
+ if the field has the integer value ``1``.
+
+ * For the other fields, the method ``execute`` invokes all methods with a name
+ that starts with 'set_'; such methods can be defined to implement the effect
+ of those fields.
+
+ The method ``default_get`` retrieves values that reflect the current status of the
+ fields like 'default_XXX', 'group_XXX' and 'module_XXX'. It also invokes all methods
+ with a name that starts with 'get_default_'; such methods can be defined to provide
+ current values for other fields.
+ """
+ _name = 'res.config.settings'
+
+ @api.multi
+ def copy(self, values):
+ raise UserError(_("Cannot duplicate configuration!"), "")
+
+ # TODO: Find replacement for 'onchange' attribute in view with dynamic
+ # api.onchange(...) and migrate the onchange_module(...) accordingly.
+ @api.model
+ def fields_view_get(self, view_id=None, view_type='form',
+ toolbar=False, submenu=False):
+ ret_val = super(ResConfigSettings, self).fields_view_get(
+ view_id=view_id, view_type=view_type,
+ toolbar=toolbar, submenu=submenu)
+
+ can_install_modules = self.env['ir.module.module'].check_access_rights(
+ 'write', raise_exception=False)
+
+ doc = etree.XML(ret_val['arch'])
+
+ for field in ret_val['fields']:
+ if not field.startswith("module_"):
+ continue
+ for node in doc.xpath("//field[@name='%s']" % field):
+ if not can_install_modules:
+ node.set("readonly", "1")
+ modifiers = json.loads(node.get("modifiers"))
+ modifiers['readonly'] = True
+ node.set("modifiers", json.dumps(modifiers))
+ if 'on_change' not in node.attrib:
+ node.set("on_change",
+ "onchange_module(%s, '%s')" % (field, field))
+
+ ret_val['arch'] = etree.tostring(doc, encoding='unicode')
+ return ret_val
+
+ @api.multi
+ def onchange_module(self, field_value, module_name):
+ ModuleSudo = self.env['ir.module.module'].sudo()
+ modules = ModuleSudo.search(
+ [('name', '=', module_name.replace("module_", '')),
+ ('state', 'in', ['to install', 'installed', 'to upgrade'])])
+
+ if modules and not field_value:
+ deps = modules.sudo().downstream_dependencies()
+ dep_names = (deps | modules).mapped('shortdesc')
+ message = '\n'.join(dep_names)
+ return {
+ 'warning': {
+ 'title': _('Warning!'),
+ 'message': _('Disabling this option will also uninstall the following modules \n%s') % message,
+ }
+ }
+ return {}
+
+ @api.model
+ def _get_classified_fields(self):
+ """ return a dictionary with the fields classified by category::
+
+ { 'default': [('default_foo', 'model', 'foo'), ...],
+ 'group': [('group_bar', [browse_group], browse_implied_group), ...],
+ 'module': [('module_baz', browse_module), ...],
+ 'other': ['other_field', ...],
+ }
+ """
+ IrModule = self.env['ir.module.module']
+ Groups = self.env['res.groups']
+ ref = self.env.ref
+
+ defaults, groups, modules, others = [], [], [], []
+ for name, field in self._fields.items():
+ if name.startswith('default_') and hasattr(field, 'default_model'):
+ defaults.append((name, field.default_model, name[8:]))
+ elif name.startswith('group_') and field.type in ('boolean', 'selection') and \
+ hasattr(field, 'implied_group'):
+ field_group_xmlids = getattr(field, 'group', 'base.group_user').split(',')
+ field_groups = Groups.concat(*(ref(it) for it in field_group_xmlids))
+ groups.append((name, field_groups, ref(field.implied_group)))
+ elif name.startswith('module_') and field.type in ('boolean', 'selection'):
+ module = IrModule.sudo().search([('name', '=', name[7:])], limit=1)
+ modules.append((name, module))
+ else:
+ others.append(name)
+
+ return {'default': defaults, 'group': groups, 'module': modules, 'other': others}
+
+ def get_values(self):
+ """
+ Return values for the fields other that `default`, `group` and `module`
+ """
+ return {}
+
+ @api.model
+ def default_get(self, fields):
+ IrDefault = self.env['ir.default']
+ classified = self._get_classified_fields()
+
+ res = super(ResConfigSettings, self).default_get(fields)
+
+ # defaults: take the corresponding default value they set
+ for name, model, field in classified['default']:
+ value = IrDefault.get(model, field)
+ if value is not None:
+ res[name] = value
+
+ # groups: which groups are implied by the group Employee
+ for name, groups, implied_group in classified['group']:
+ res[name] = all(implied_group in group.implied_ids for group in groups)
+ if self._fields[name].type == 'selection':
+ res[name] = int(res[name])
+
+ # modules: which modules are installed/to install
+ for name, module in classified['module']:
+ res[name] = module.state in ('installed', 'to install', 'to upgrade')
+ if self._fields[name].type == 'selection':
+ res[name] = int(res[name])
+
+ # other fields: call the method 'get_values'
+ # The other methods that start with `get_default_` are deprecated
+ for method in dir(self):
+ if method.startswith('get_default_'):
+ _logger.warning(_('Methods that start with `get_default_` are deprecated. Override `get_values` instead(Method %s)') % method)
+ res.update(self.get_values())
+
+ return res
+
+ def set_values(self):
+ """
+ Set values for the fields other that `default`, `group` and `module`
+ """
+ pass
+
+ @api.multi
+ def execute(self):
+ self.ensure_one()
+ if not self.env.user._is_superuser() and not self.env.user.has_group('base.group_system'):
+ raise AccessError(_("Only administrators can change the settings"))
+
+ self = self.with_context(active_test=False)
+ classified = self._get_classified_fields()
+
+ # default values fields
+ IrDefault = self.env['ir.default'].sudo()
+ for name, model, field in classified['default']:
+ if isinstance(self[name], models.BaseModel):
+ if self._fields[name].type == 'many2one':
+ value = self[name].id
+ else:
+ value = self[name].ids
+ else:
+ value = self[name]
+ IrDefault.set(model, field, value)
+
+ # group fields: modify group / implied groups
+ for name, groups, implied_group in classified['group']:
+ if self[name]:
+ groups.write({'implied_ids': [(4, implied_group.id)]})
+ else:
+ groups.write({'implied_ids': [(3, implied_group.id)]})
+ implied_group.write({'users': [(3, user.id) for user in groups.mapped('users')]})
+
+ # other fields: execute method 'set_values'
+ # Methods that start with `set_` are now deprecated
+ for method in dir(self):
+ if method.startswith('set_') and method is not 'set_values':
+ _logger.warning(_('Methods that start with `set_` are deprecated. Override `set_values` instead (Method %s)') % method)
+ self.set_values()
+
+ # module fields: install/uninstall the selected modules
+ to_install = []
+ to_uninstall_modules = self.env['ir.module.module']
+ lm = len('module_')
+ for name, module in classified['module']:
+ if self[name]:
+ to_install.append((name[lm:], module))
+ else:
+ if module and module.state in ('installed', 'to upgrade'):
+ to_uninstall_modules += module
+
+ if to_uninstall_modules:
+ to_uninstall_modules.button_immediate_uninstall()
+
+ self._install_modules(to_install)
+
+ if to_install or to_uninstall_modules:
+ # After the uninstall/install calls, the registry and environments
+ # are no longer valid. So we reset the environment.
+ self.env.reset()
+ self = self.env()[self._name]
+
+ # pylint: disable=next-method-called
+ config = self.env['res.config'].next() or {}
+ if config.get('type') not in ('ir.actions.act_window_close',):
+ return config
+
+ # force client-side reload (update user menu and current view)
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'reload',
+ }
+
+ @api.multi
+ def cancel(self):
+ # ignore the current record, and send the action to reopen the view
+ actions = self.env['ir.actions.act_window'].search([('res_model', '=', self._name)], limit=1)
+ if actions:
+ return actions.read()[0]
+ return {}
+
+ @api.multi
+ def name_get(self):
+ """ Override name_get method to return an appropriate configuration wizard
+ name, and not the generated name."""
+ action = self.env['ir.actions.act_window'].search([('res_model', '=', self._name)], limit=1)
+ name = action.name or self._name
+ return [(record.id, name) for record in self]
+
+ @api.model
+ def get_option_path(self, menu_xml_id):
+ """
+ Fetch the path to a specified configuration view and the action id to access it.
+
+ :param string menu_xml_id: the xml id of the menuitem where the view is located,
+ structured as follows: module_name.menuitem_xml_id (e.g.: "sales_team.menu_sale_config")
+ :return tuple:
+ - t[0]: string: full path to the menuitem (e.g.: "Settings/Configuration/Sales")
+ - t[1]: int or long: id of the menuitem's action
+ """
+ ir_ui_menu = self.env.ref(menu_xml_id)
+ return (ir_ui_menu.complete_name, ir_ui_menu.action.id)
+
+ @api.model
+ def get_option_name(self, full_field_name):
+ """
+ Fetch the human readable name of a specified configuration option.
+
+ :param string full_field_name: the full name of the field, structured as follows:
+ model_name.field_name (e.g.: "sale.config.settings.fetchmail_lead")
+ :return string: human readable name of the field (e.g.: "Create leads from incoming mails")
+ """
+ model_name, field_name = full_field_name.rsplit('.', 1)
+ return self.env[model_name].fields_get([field_name])[field_name]['string']
+
+ @api.model_cr_context
+ def get_config_warning(self, msg):
+ """
+ Helper: return a Warning exception with the given message where the %(field:xxx)s
+ and/or %(menu:yyy)s are replaced by the human readable field's name and/or menuitem's
+ full path.
+
+ Usage:
+ ------
+ Just include in your error message %(field:model_name.field_name)s to obtain the human
+ readable field's name, and/or %(menu:module_name.menuitem_xml_id)s to obtain the menuitem's
+ full path.
+
+ Example of use:
+ ---------------
+ from odoo.addons.base.models.res_config import get_warning_config
+ raise get_warning_config(cr, _("Error: this action is prohibited. You should check the field %(field:sale.config.settings.fetchmail_lead)s in %(menu:sales_team.menu_sale_config)s."), context=context)
+
+ This will return an exception containing the following message:
+ Error: this action is prohibited. You should check the field Create leads from incoming mails in Settings/Configuration/Sales.
+
+ What if there is another substitution in the message already?
+ -------------------------------------------------------------
+ You could have a situation where the error message you want to upgrade already contains a substitution. Example:
+ Cannot find any account journal of %s type for this company.\n\nYou can create one in the menu: \nConfiguration\Journals\Journals.
+ What you want to do here is simply to replace the path by %menu:account.menu_account_config)s, and leave the rest alone.
+ In order to do that, you can use the double percent (%%) to escape your new substitution, like so:
+ Cannot find any account journal of %s type for this company.\n\nYou can create one in the %%(menu:account.menu_account_config)s.
+ """
+ self = self.sudo()
+
+ # Process the message
+ # 1/ find the menu and/or field references, put them in a list
+ regex_path = r'%\(((?:menu|field):[a-z_\.]*)\)s'
+ references = re.findall(regex_path, msg, flags=re.I)
+
+ # 2/ fetch the menu and/or field replacement values (full path and
+ # human readable field's name) and the action_id if any
+ values = {}
+ action_id = None
+ for item in references:
+ ref_type, ref = item.split(':')
+ if ref_type == 'menu':
+ values[item], action_id = self.get_option_path(ref)
+ elif ref_type == 'field':
+ values[item] = self.get_option_name(ref)
+
+ # 3/ substitute and return the result
+ if (action_id):
+ return RedirectWarning(msg % values, action_id, _('Go to the configuration panel'))
+ return UserError(msg % values)
diff --git a/odoo/addons/base/models/res_country.py b/odoo/addons/base/models/res_country.py
new file mode 100644
index 00000000000..a3984155b4e
--- /dev/null
+++ b/odoo/addons/base/models/res_country.py
@@ -0,0 +1,131 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import re
+import logging
+from odoo import api, fields, models
+from psycopg2 import IntegrityError
+from odoo.tools.translate import _
+_logger = logging.getLogger(__name__)
+
+
+@api.model
+def location_name_search(self, name='', args=None, operator='ilike', limit=100):
+ if args is None:
+ args = []
+
+ records = self.browse()
+ if len(name) == 2:
+ records = self.search([('code', 'ilike', name)] + args, limit=limit)
+
+ search_domain = [('name', operator, name)]
+ if records:
+ search_domain.append(('id', 'not in', records.ids))
+ records += self.search(search_domain + args, limit=limit)
+
+ # the field 'display_name' calls name_get() to get its value
+ return [(record.id, record.display_name) for record in records]
+
+
+class Country(models.Model):
+ _name = 'res.country'
+ _description = 'Country'
+ _order = 'name'
+
+ name = fields.Char(
+ string='Country Name', required=True, translate=True, help='The full name of the country.')
+ code = fields.Char(
+ string='Country Code', size=2,
+ help='The ISO country code in two chars. \nYou can use this field for quick search.')
+ address_format = fields.Text(string="Layout in Reports",
+ help="Display format to use for addresses belonging to this country.\n\n"
+ "You can use python-style string pattern with all the fields of the address "
+ "(for example, use '%(street)s' to display the field 'street') plus"
+ "\n%(state_name)s: the name of the state"
+ "\n%(state_code)s: the code of the state"
+ "\n%(country_name)s: the name of the country"
+ "\n%(country_code)s: the code of the country",
+ default='%(street)s\n%(street2)s\n%(city)s %(state_code)s %(zip)s\n%(country_name)s')
+ address_view_id = fields.Many2one(
+ comodel_name='ir.ui.view', string="Input View",
+ domain=[('model', '=', 'res.partner'), ('type', '=', 'form')],
+ help="Use this field if you want to replace the usual way to encode a complete address. "
+ "Note that the address_format field is used to modify the way to display addresses "
+ "(in reports for example), while this field is used to modify the input form for "
+ "addresses.")
+ currency_id = fields.Many2one('res.currency', string='Currency')
+ image = fields.Binary(attachment=True)
+ phone_code = fields.Integer(string='Country Calling Code')
+ country_group_ids = fields.Many2many('res.country.group', 'res_country_res_country_group_rel',
+ 'res_country_id', 'res_country_group_id', string='Country Groups')
+ state_ids = fields.One2many('res.country.state', 'country_id', string='States')
+ name_position = fields.Selection([
+ ('before', 'Before Address'),
+ ('after', 'After Address'),
+ ], string="Customer Name Position", default="before",
+ help="Determines where the customer/company name should be placed, i.e. after or before the address.")
+ vat_label = fields.Char(string='Vat Label', translate=True, help="Use this field if you want to change vat label.")
+
+ _sql_constraints = [
+ ('name_uniq', 'unique (name)',
+ 'The name of the country must be unique !'),
+ ('code_uniq', 'unique (code)',
+ 'The code of the country must be unique !')
+ ]
+
+ name_search = location_name_search
+
+ @api.model
+ def create(self, vals):
+ if vals.get('code'):
+ vals['code'] = vals['code'].upper()
+ return super(Country, self).create(vals)
+
+ @api.multi
+ def write(self, vals):
+ if vals.get('code'):
+ vals['code'] = vals['code'].upper()
+ return super(Country, self).write(vals)
+
+ @api.multi
+ def get_address_fields(self):
+ self.ensure_one()
+ return re.findall(r'\((.+?)\)', self.address_format)
+
+
+class CountryGroup(models.Model):
+ _description = "Country Group"
+ _name = 'res.country.group'
+
+ name = fields.Char(required=True, translate=True)
+ country_ids = fields.Many2many('res.country', 'res_country_res_country_group_rel',
+ 'res_country_group_id', 'res_country_id', string='Countries')
+
+
+class CountryState(models.Model):
+ _description = "Country state"
+ _name = 'res.country.state'
+ _order = 'code'
+
+ country_id = fields.Many2one('res.country', string='Country', required=True)
+ name = fields.Char(string='State Name', required=True,
+ help='Administrative divisions of a country. E.g. Fed. State, Departement, Canton')
+ code = fields.Char(string='State Code', help='The state code.', required=True)
+
+ _sql_constraints = [
+ ('name_code_uniq', 'unique(country_id, code)', 'The code of the state must be unique by country !')
+ ]
+
+ @api.model
+ def name_search(self, name='', args=None, operator='ilike', limit=100):
+ if args is None:
+ args = []
+ if self.env.context.get('country_id'):
+ args = args + [('country_id', '=', self.env.context.get('country_id'))]
+ firsts_records = self.search([('code', '=ilike', name)] + args, limit=limit)
+ search_domain = [('name', operator, name)]
+ search_domain.append(('id', 'not in', firsts_records.ids))
+ records = firsts_records + self.search(search_domain + args, limit=limit)
+ return [(record.id, record.display_name) for record in records]
+
+
diff --git a/odoo/addons/base/models/res_currency.py b/odoo/addons/base/models/res_currency.py
new file mode 100644
index 00000000000..7a352e62587
--- /dev/null
+++ b/odoo/addons/base/models/res_currency.py
@@ -0,0 +1,251 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import json
+import logging
+import math
+import re
+import time
+
+try:
+ from num2words import num2words
+except ImportError:
+ logging.getLogger(__name__).warning("The num2words python library is not installed, l10n_mx_edi features won't be fully available.")
+ num2words = None
+
+from odoo import api, fields, models, tools, _
+
+CURRENCY_DISPLAY_PATTERN = re.compile(r'(\w+)\s*(?:\((.*)\))?')
+
+
+class Currency(models.Model):
+ _name = "res.currency"
+ _description = "Currency"
+ _order = 'active desc, name'
+
+ # Note: 'code' column was removed as of v6.0, the 'name' should now hold the ISO code.
+ name = fields.Char(string='Currency', size=3, required=True, help="Currency Code (ISO 4217)")
+ symbol = fields.Char(help="Currency sign, to be used when printing amounts.", required=True)
+ rate = fields.Float(compute='_compute_current_rate', string='Current Rate', digits=(12, 6),
+ help='The rate of the currency to the currency of rate 1.')
+ rate_ids = fields.One2many('res.currency.rate', 'currency_id', string='Rates')
+ rounding = fields.Float(string='Rounding Factor', digits=(12, 6), default=0.01)
+ decimal_places = fields.Integer(compute='_compute_decimal_places')
+ active = fields.Boolean(default=True)
+ position = fields.Selection([('after', 'After Amount'), ('before', 'Before Amount')], default='after',
+ string='Symbol Position', help="Determines where the currency symbol should be placed after or before the amount.")
+ date = fields.Date(compute='_compute_date')
+ currency_unit_label = fields.Char(string="Currency Unit", help="Currency Unit Name")
+ currency_subunit_label = fields.Char(string="Currency Subunit", help="Currency Subunit Name")
+
+ _sql_constraints = [
+ ('unique_name', 'unique (name)', 'The currency code must be unique!'),
+ ('rounding_gt_zero', 'CHECK (rounding>0)', 'The rounding factor must be greater than 0!')
+ ]
+
+ @api.multi
+ def _compute_current_rate(self):
+ date = self._context.get('date') or fields.Date.today()
+ company_id = self._context.get('company_id') or self.env['res.users']._get_company().id
+ # the subquery selects the last rate before 'date' for the given currency/company
+ query = """SELECT c.id, (SELECT r.rate FROM res_currency_rate r
+ WHERE r.currency_id = c.id AND r.name <= %s
+ AND (r.company_id IS NULL OR r.company_id = %s)
+ ORDER BY r.company_id, r.name DESC
+ LIMIT 1) AS rate
+ FROM res_currency c
+ WHERE c.id IN %s"""
+ self._cr.execute(query, (date, company_id, tuple(self.ids)))
+ currency_rates = dict(self._cr.fetchall())
+ for currency in self:
+ currency.rate = currency_rates.get(currency.id) or 1.0
+
+ @api.multi
+ @api.depends('rounding')
+ def _compute_decimal_places(self):
+ for currency in self:
+ if 0 < currency.rounding < 1:
+ currency.decimal_places = int(math.ceil(math.log10(1/currency.rounding)))
+ else:
+ currency.decimal_places = 0
+
+ @api.multi
+ @api.depends('rate_ids.name')
+ def _compute_date(self):
+ for currency in self:
+ currency.date = currency.rate_ids[:1].name
+
+ @api.model
+ def name_search(self, name='', args=None, operator='ilike', limit=100):
+ results = super(Currency, self).name_search(name, args, operator=operator, limit=limit)
+ if not results:
+ name_match = CURRENCY_DISPLAY_PATTERN.match(name)
+ if name_match:
+ results = super(Currency, self).name_search(name_match.group(1), args, operator=operator, limit=limit)
+ return results
+
+ @api.multi
+ def name_get(self):
+ return [(currency.id, tools.ustr(currency.name)) for currency in self]
+
+ @api.multi
+ def amount_to_text(self, amount):
+ self.ensure_one()
+ def _num2words(number, lang):
+ try:
+ return num2words(number, lang=lang).title()
+ except NotImplementedError:
+ return num2words(number, lang='en').title()
+
+ if num2words is None:
+ logging.getLogger(__name__).warning("The library 'num2words' is missing, cannot render textual amounts.")
+ return ""
+
+ fractional_value, integer_value = math.modf(amount)
+ fractional_amount = round(abs(fractional_value), self.decimal_places) * (math.pow(10, self.decimal_places))
+ lang_code = self.env.context.get('lang') or self.env.user.lang
+ lang = self.env['res.lang'].search([('code', '=', lang_code)])
+ amount_words = tools.ustr('{amt_value} {amt_word}').format(
+ amt_value=_num2words(int(integer_value), lang=lang.iso_code),
+ amt_word=self.currency_unit_label,
+ )
+ if not self.is_zero(fractional_value):
+ amount_words += ' ' + _('and') + tools.ustr(' {amt_value} {amt_word}').format(
+ amt_value=_num2words(int(fractional_amount), lang=lang.iso_code),
+ amt_word=self.currency_subunit_label,
+ )
+ return amount_words
+
+ @api.multi
+ def round(self, amount):
+ """Return ``amount`` rounded according to ``self``'s rounding rules.
+
+ :param float amount: the amount to round
+ :return: rounded float
+ """
+ # TODO: Need to check why it calls round() from sale.py, _amount_all() with *No* ID after below commits,
+ # https://github.com/odoo/odoo/commit/36ee1ad813204dcb91e9f5f20d746dff6f080ac2
+ # https://github.com/odoo/odoo/commit/0b6058c585d7d9a57bd7581b8211f20fca3ec3f7
+ # Removing self.ensure_one() will make few test cases to break of modules event_sale, sale_mrp and stock_dropshipping.
+ #self.ensure_one()
+ return tools.float_round(amount, precision_rounding=self.rounding)
+
+ @api.multi
+ def compare_amounts(self, amount1, amount2):
+ """Compare ``amount1`` and ``amount2`` after rounding them according to the
+ given currency's precision..
+ An amount is considered lower/greater than another amount if their rounded
+ value is different. This is not the same as having a non-zero difference!
+
+ For example 1.432 and 1.431 are equal at 2 digits precision,
+ so this method would return 0.
+ However 0.006 and 0.002 are considered different (returns 1) because
+ they respectively round to 0.01 and 0.0, even though
+ 0.006-0.002 = 0.004 which would be considered zero at 2 digits precision.
+
+ :param float amount1: first amount to compare
+ :param float amount2: second amount to compare
+ :return: (resp.) -1, 0 or 1, if ``amount1`` is (resp.) lower than,
+ equal to, or greater than ``amount2``, according to
+ ``currency``'s rounding.
+
+ With the new API, call it like: ``currency.compare_amounts(amount1, amount2)``.
+ """
+ return tools.float_compare(amount1, amount2, precision_rounding=self.rounding)
+
+ @api.multi
+ def is_zero(self, amount):
+ """Returns true if ``amount`` is small enough to be treated as
+ zero according to current currency's rounding rules.
+ Warning: ``is_zero(amount1-amount2)`` is not always equivalent to
+ ``compare_amounts(amount1,amount2) == 0``, as the former will round after
+ computing the difference, while the latter will round before, giving
+ different results for e.g. 0.006 and 0.002 at 2 digits precision.
+
+ :param float amount: amount to compare with currency's zero
+
+ With the new API, call it like: ``currency.is_zero(amount)``.
+ """
+ return tools.float_is_zero(amount, precision_rounding=self.rounding)
+
+ @api.model
+ def _get_conversion_rate(self, from_currency, to_currency):
+ from_currency = from_currency.with_env(self.env)
+ to_currency = to_currency.with_env(self.env)
+ return to_currency.rate / from_currency.rate
+
+ @api.model
+ def _compute(self, from_currency, to_currency, from_amount, round=True):
+ if (to_currency == from_currency):
+ amount = to_currency.round(from_amount) if round else from_amount
+ else:
+ rate = self._get_conversion_rate(from_currency, to_currency)
+ amount = to_currency.round(from_amount * rate) if round else from_amount * rate
+ return amount
+
+ @api.multi
+ def compute(self, from_amount, to_currency, round=True):
+ """ Convert `from_amount` from currency `self` to `to_currency`. """
+ self, to_currency = self or to_currency, to_currency or self
+ assert self, "compute from unknown currency"
+ assert to_currency, "compute to unknown currency"
+ # apply conversion rate
+ if self == to_currency:
+ to_amount = from_amount
+ else:
+ to_amount = from_amount * self._get_conversion_rate(self, to_currency)
+ # apply rounding
+ return to_currency.round(to_amount) if round else to_amount
+
+ def _select_companies_rates(self):
+ return """
+ SELECT
+ r.currency_id,
+ COALESCE(r.company_id, c.id) as company_id,
+ r.rate,
+ r.name AS date_start,
+ (SELECT name FROM res_currency_rate r2
+ WHERE r2.name > r.name AND
+ r2.currency_id = r.currency_id AND
+ (r2.company_id is null or r2.company_id = c.id)
+ ORDER BY r2.name ASC
+ LIMIT 1) AS date_end
+ FROM res_currency_rate r
+ JOIN res_company c ON (r.company_id is null or r.company_id = c.id)
+ """
+
+
+class CurrencyRate(models.Model):
+ _name = "res.currency.rate"
+ _description = "Currency Rate"
+ _order = "name desc"
+
+ name = fields.Date(string='Date', required=True, index=True,
+ default=lambda self: fields.Date.today())
+ rate = fields.Float(digits=(12, 6), help='The rate of the currency to the currency of rate 1')
+ currency_id = fields.Many2one('res.currency', string='Currency', readonly=True)
+ company_id = fields.Many2one('res.company', string='Company',
+ default=lambda self: self.env.user.company_id)
+
+ _sql_constraints = [
+ ('unique_name_per_day', 'unique (name,currency_id,company_id)', 'Only one currency rate per day allowed!'),
+ ]
+
+ @api.model
+ def name_search(self, name, args=None, operator='ilike', limit=80):
+ if operator in ['=', '!=']:
+ try:
+ date_format = '%Y-%m-%d'
+ if self._context.get('lang'):
+ langs = self.env['res.lang'].search([('code', '=', self._context['lang'])])
+ if langs:
+ date_format = langs.date_format
+ name = time.strftime('%Y-%m-%d', time.strptime(name, date_format))
+ except ValueError:
+ try:
+ args.append(('rate', operator, float(name)))
+ except ValueError:
+ return []
+ name = ''
+ operator = 'ilike'
+ return super(CurrencyRate, self).name_search(name, args=args, operator=operator, limit=limit)
diff --git a/odoo/addons/base/models/res_lang.py b/odoo/addons/base/models/res_lang.py
new file mode 100644
index 00000000000..38b4209cc6f
--- /dev/null
+++ b/odoo/addons/base/models/res_lang.py
@@ -0,0 +1,317 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import json
+import locale
+import logging
+import re
+from operator import itemgetter
+
+from odoo import api, fields, models, tools, _
+from odoo.tools.safe_eval import safe_eval
+from odoo.exceptions import UserError, ValidationError
+
+_logger = logging.getLogger(__name__)
+
+DEFAULT_DATE_FORMAT = '%m/%d/%Y'
+DEFAULT_TIME_FORMAT = '%H:%M:%S'
+
+
+class Lang(models.Model):
+ _name = "res.lang"
+ _description = "Languages"
+ _order = "active desc,name"
+
+ _disallowed_datetime_patterns = list(tools.DATETIME_FORMATS_MAP)
+ _disallowed_datetime_patterns.remove('%y') # this one is in fact allowed, just not good practice
+
+ name = fields.Char(required=True)
+ code = fields.Char(string='Locale Code', required=True, help='This field is used to set/get locales for user')
+ iso_code = fields.Char(string='ISO code', help='This ISO code is the name of po files to use for translations')
+ translatable = fields.Boolean()
+ active = fields.Boolean()
+ direction = fields.Selection([('ltr', 'Left-to-Right'), ('rtl', 'Right-to-Left')], required=True, default='ltr')
+ date_format = fields.Char(string='Date Format', required=True, default=DEFAULT_DATE_FORMAT)
+ time_format = fields.Char(string='Time Format', required=True, default=DEFAULT_TIME_FORMAT)
+ grouping = fields.Char(string='Separator Format', required=True, default='[]',
+ help="The Separator Format should be like [,n] where 0 < n :starting from Unit digit. "
+ "-1 will end the separation. e.g. [3,2,-1] will represent 106500 to be 1,06,500; "
+ "[1,2,-1] will represent it to be 106,50,0;[3] will represent it as 106,500. "
+ "Provided ',' as the thousand separator in each case.")
+ decimal_point = fields.Char(string='Decimal Separator', required=True, default='.', trim=False)
+ thousands_sep = fields.Char(string='Thousands Separator', default=',', trim=False)
+
+ _sql_constraints = [
+ ('name_uniq', 'unique(name)', 'The name of the language must be unique !'),
+ ('code_uniq', 'unique(code)', 'The code of the language must be unique !'),
+ ]
+
+ @api.constrains('active')
+ def _check_active(self):
+ # do not check during installation
+ if self.env.registry.ready and not self.search_count([]):
+ raise ValidationError(_('At least one language must be active.'))
+
+ @api.constrains('time_format', 'date_format')
+ def _check_format(self):
+ for lang in self:
+ for pattern in lang._disallowed_datetime_patterns:
+ if (lang.time_format and pattern in lang.time_format) or \
+ (lang.date_format and pattern in lang.date_format):
+ raise ValidationError(_('Invalid date/time format directive specified. '
+ 'Please refer to the list of allowed directives, '
+ 'displayed when you edit a language.'))
+
+ @api.constrains('grouping')
+ def _check_grouping(self):
+ warning = _('The Separator Format should be like [,n] where 0 < n :starting from Unit digit. '
+ '-1 will end the separation. e.g. [3,2,-1] will represent 106500 to be 1,06,500;'
+ '[1,2,-1] will represent it to be 106,50,0;[3] will represent it as 106,500. '
+ 'Provided as the thousand separator in each case.')
+ for lang in self:
+ try:
+ if not all(isinstance(x, int) for x in json.loads(lang.grouping)):
+ raise ValidationError(warning)
+ except Exception:
+ raise ValidationError(warning)
+
+ @api.model_cr
+ def _register_hook(self):
+ # check that there is at least one active language
+ if not self.search_count([]):
+ _logger.error("No language is active.")
+
+ @api.model
+ def load_lang(self, lang, lang_name=None):
+ """ Create the given language if necessary, and make it active. """
+ # if the language exists, simply make it active
+ language = self.with_context(active_test=False).search([('code', '=', lang)], limit=1)
+ if language:
+ language.write({'active': True})
+ return language.id
+
+ # create the language with locale information
+ fail = True
+ iso_lang = tools.get_iso_codes(lang)
+ for ln in tools.get_locales(lang):
+ try:
+ locale.setlocale(locale.LC_ALL, str(ln))
+ fail = False
+ break
+ except locale.Error:
+ continue
+ if fail:
+ lc = locale.getdefaultlocale()[0]
+ msg = 'Unable to get information for locale %s. Information from the default locale (%s) have been used.'
+ _logger.warning(msg, lang, lc)
+
+ if not lang_name:
+ lang_name = lang
+
+ def fix_xa0(s):
+ """Fix badly-encoded non-breaking space Unicode character from locale.localeconv(),
+ coercing to utf-8, as some platform seem to output localeconv() in their system
+ encoding, e.g. Windows-1252"""
+ if s == '\xa0':
+ return '\xc2\xa0'
+ return s
+
+ def fix_datetime_format(format):
+ """Python's strftime supports only the format directives
+ that are available on the platform's libc, so in order to
+ be 100% cross-platform we map to the directives required by
+ the C standard (1989 version), always available on platforms
+ with a C standard implementation."""
+ # For some locales, nl_langinfo returns a D_FMT/T_FMT that contains
+ # unsupported '%-' patterns, e.g. for cs_CZ
+ format = format.replace('%-', '%')
+ for pattern, replacement in tools.DATETIME_FORMATS_MAP.items():
+ format = format.replace(pattern, replacement)
+ return str(format)
+
+ conv = locale.localeconv()
+ lang_info = {
+ 'code': lang,
+ 'iso_code': iso_lang,
+ 'name': lang_name,
+ 'active': True,
+ 'translatable': True,
+ 'date_format' : fix_datetime_format(locale.nl_langinfo(locale.D_FMT)),
+ 'time_format' : fix_datetime_format(locale.nl_langinfo(locale.T_FMT)),
+ 'decimal_point' : fix_xa0(str(conv['decimal_point'])),
+ 'thousands_sep' : fix_xa0(str(conv['thousands_sep'])),
+ 'grouping' : str(conv.get('grouping', [])),
+ }
+ try:
+ return self.create(lang_info).id
+ finally:
+ tools.resetlocale()
+
+ @api.model
+ def install_lang(self):
+ """
+
+ This method is called from odoo/addons/base/base_data.xml to load
+ some language and set it as the default for every partners. The
+ language is set via tools.config by the RPC 'create' method on the
+ 'db' object. This is a fragile solution and something else should be
+ found.
+
+ """
+ # config['load_language'] is a comma-separated list or None
+ lang_code = (tools.config.get('load_language') or 'en_US').split(',')[0]
+ lang = self.search([('code', '=', lang_code)])
+ if not lang:
+ self.load_lang(lang_code)
+ IrDefault = self.env['ir.default']
+ default_value = IrDefault.get('res.partner', 'lang')
+ if default_value is None:
+ IrDefault.set('res.partner', 'lang', lang_code)
+ # set language of main company, created directly by db bootstrap SQL
+ partner = self.env.user.company_id.partner_id
+ if not partner.lang:
+ partner.write({'lang': lang_code})
+ return True
+
+ @tools.ormcache('code')
+ def _lang_get_id(self, code):
+ return (self.search([('code', '=', code)]) or
+ self.search([('code', '=', 'en_US')]) or
+ self.search([], limit=1)).id
+
+ @api.model
+ @api.returns('self', lambda value: value.id)
+ def _lang_get(self, code):
+ return self.browse(self._lang_get_id(code))
+
+ @tools.ormcache('self.code', 'monetary')
+ def _data_get(self, monetary=False):
+ conv = locale.localeconv()
+ thousands_sep = self.thousands_sep or conv[monetary and 'mon_thousands_sep' or 'thousands_sep']
+ decimal_point = self.decimal_point
+ grouping = self.grouping
+ return grouping, thousands_sep, decimal_point
+
+ @api.model
+ @tools.ormcache()
+ def get_available(self):
+ """ Return the available languages as a list of (code, name) sorted by name. """
+ langs = self.with_context(active_test=False).search([])
+ return sorted([(lang.code, lang.name) for lang in langs], key=itemgetter(1))
+
+ @api.model
+ @tools.ormcache()
+ def get_installed(self):
+ """ Return the installed languages as a list of (code, name) sorted by name. """
+ langs = self.with_context(active_test=True).search([])
+ return sorted([(lang.code, lang.name) for lang in langs], key=itemgetter(1))
+
+ @api.model
+ def create(self, vals):
+ self.clear_caches()
+ return super(Lang, self).create(vals)
+
+ @api.multi
+ def write(self, vals):
+ lang_codes = self.mapped('code')
+ if 'code' in vals and any(code != vals['code'] for code in lang_codes):
+ raise UserError(_("Language code cannot be modified."))
+ if vals.get('active') == False:
+ if self.env['res.users'].search([('lang', 'in', lang_codes)]):
+ raise UserError(_("Cannot unactivate a language that is currently used by users."))
+ # delete linked ir.default specifying default partner's language
+ self.env['ir.default'].discard_values('res.partner', 'lang', lang_codes)
+
+ res = super(Lang, self).write(vals)
+ self.clear_caches()
+ return res
+
+ @api.multi
+ def unlink(self):
+ for language in self:
+ if language.code == 'en_US':
+ raise UserError(_("Base Language 'en_US' can not be deleted!"))
+ ctx_lang = self._context.get('lang')
+ if ctx_lang and (language.code == ctx_lang):
+ raise UserError(_("You cannot delete the language which is User's Preferred Language!"))
+ if language.active:
+ raise UserError(_("You cannot delete the language which is Active!\nPlease de-activate the language first."))
+ self.env['ir.translation'].search([('lang', '=', language.code)]).unlink()
+ self.clear_caches()
+ return super(Lang, self).unlink()
+
+ @api.multi
+ def format(self, percent, value, grouping=False, monetary=False):
+ """ Format() will return the language-specific output for float values"""
+ self.ensure_one()
+ if percent[0] != '%':
+ raise ValueError(_("format() must be given exactly one %char format specifier"))
+
+ formatted = percent % value
+
+ # floats and decimal ints need special action!
+ if grouping:
+ lang_grouping, thousands_sep, decimal_point = self._data_get(monetary)
+ eval_lang_grouping = safe_eval(lang_grouping)
+
+ if percent[-1] in 'eEfFgG':
+ parts = formatted.split('.')
+ parts[0] = intersperse(parts[0], eval_lang_grouping, thousands_sep)[0]
+
+ formatted = decimal_point.join(parts)
+
+ elif percent[-1] in 'diu':
+ formatted = intersperse(formatted, eval_lang_grouping, thousands_sep)[0]
+
+ return formatted
+
+
+def split(l, counts):
+ """
+
+ >>> split("hello world", [])
+ ['hello world']
+ >>> split("hello world", [1])
+ ['h', 'ello world']
+ >>> split("hello world", [2])
+ ['he', 'llo world']
+ >>> split("hello world", [2,3])
+ ['he', 'llo', ' world']
+ >>> split("hello world", [2,3,0])
+ ['he', 'llo', ' wo', 'rld']
+ >>> split("hello world", [2,-1,3])
+ ['he', 'llo world']
+
+ """
+ res = []
+ saved_count = len(l) # count to use when encoutering a zero
+ for count in counts:
+ if not l:
+ break
+ if count == -1:
+ break
+ if count == 0:
+ while l:
+ res.append(l[:saved_count])
+ l = l[saved_count:]
+ break
+ res.append(l[:count])
+ l = l[count:]
+ saved_count = count
+ if l:
+ res.append(l)
+ return res
+
+intersperse_pat = re.compile('([^0-9]*)([^ ]*)(.*)')
+
+def intersperse(string, counts, separator=''):
+ """
+
+ See the asserts below for examples.
+
+ """
+ left, rest, right = intersperse_pat.match(string).groups()
+ def reverse(s): return s[::-1]
+ splits = split(reverse(rest), counts)
+ res = separator.join(reverse(s) for s in reverse(splits))
+ return left + res + right, len(splits) > 0 and len(splits) -1 or 0
diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py
new file mode 100644
index 00000000000..58ab2953e4e
--- /dev/null
+++ b/odoo/addons/base/models/res_partner.py
@@ -0,0 +1,823 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import base64
+import datetime
+import hashlib
+import pytz
+import threading
+
+from email.utils import formataddr
+
+import requests
+from lxml import etree
+from werkzeug import urls
+
+from odoo import api, fields, models, tools, SUPERUSER_ID, _
+from odoo.modules import get_module_resource
+from odoo.osv.expression import get_unaccent_wrapper
+from odoo.exceptions import UserError, ValidationError
+
+# Global variables used for the warning fields declared on the res.partner
+# in the following modules : sale, purchase, account, stock
+WARNING_MESSAGE = [
+ ('no-message','No Message'),
+ ('warning','Warning'),
+ ('block','Blocking Message')
+ ]
+WARNING_HELP = _('Selecting the "Warning" option will notify user with the message, Selecting "Blocking Message" will throw an exception with the message and block the flow. The Message has to be written in the next field.')
+
+
+ADDRESS_FIELDS = ('street', 'street2', 'zip', 'city', 'state_id', 'country_id')
+@api.model
+def _lang_get(self):
+ return self.env['res.lang'].get_installed()
+
+@api.model
+def _tz_get(self):
+ # put POSIX 'Etc/*' entries at the end to avoid confusing users - see bug 1086728
+ return [(tz, tz) for tz in sorted(pytz.all_timezones, key=lambda tz: tz if not tz.startswith('Etc/') else '_')]
+
+
+class FormatAddressMixin(models.AbstractModel):
+ _name = "format.address.mixin"
+
+ def _fields_view_get_address(self, arch):
+ # consider the country of the user, not the country of the partner we want to display
+ address_view_id = self.env.user.company_id.country_id.address_view_id
+ if address_view_id and not self._context.get('no_address_format'):
+ #render the partner address accordingly to address_view_id
+ doc = etree.fromstring(arch)
+ for address_node in doc.xpath("//div[hasclass('o_address_format')]"):
+ Partner = self.env['res.partner'].with_context(no_address_format=True)
+ sub_view = Partner.fields_view_get(
+ view_id=address_view_id.id, view_type='form', toolbar=False, submenu=False)
+ sub_view_node = etree.fromstring(sub_view['arch'])
+ #if the model is different than res.partner, there are chances that the view won't work
+ #(e.g fields not present on the model). In that case we just return arch
+ if self._name != 'res.partner':
+ try:
+ self.env['ir.ui.view'].postprocess_and_fields(self._name, sub_view_node, None)
+ except ValueError:
+ return arch
+ address_node.getparent().replace(address_node, sub_view_node)
+ arch = etree.tostring(doc, encoding='unicode')
+ return arch
+
+class PartnerCategory(models.Model):
+ _description = 'Partner Tags'
+ _name = 'res.partner.category'
+ _order = 'parent_left, name'
+ _parent_store = True
+ _parent_order = 'name'
+
+ name = fields.Char(string='Tag Name', required=True, translate=True)
+ color = fields.Integer(string='Color Index')
+ parent_id = fields.Many2one('res.partner.category', string='Parent Category', index=True, ondelete='cascade')
+ child_ids = fields.One2many('res.partner.category', 'parent_id', string='Child Tags')
+ active = fields.Boolean(default=True, help="The active field allows you to hide the category without removing it.")
+ parent_left = fields.Integer(string='Left parent', index=True)
+ parent_right = fields.Integer(string='Right parent', index=True)
+ partner_ids = fields.Many2many('res.partner', column1='category_id', column2='partner_id', string='Partners')
+
+ @api.constrains('parent_id')
+ def _check_parent_id(self):
+ if not self._check_recursion():
+ raise ValidationError(_('Error ! You can not create recursive tags.'))
+
+ @api.multi
+ def name_get(self):
+ """ Return the categories' display name, including their direct
+ parent by default.
+
+ If ``context['partner_category_display']`` is ``'short'``, the short
+ version of the category name (without the direct parent) is used.
+ The default is the long version.
+ """
+ if self._context.get('partner_category_display') == 'short':
+ return super(PartnerCategory, self).name_get()
+
+ res = []
+ for category in self:
+ names = []
+ current = category
+ while current:
+ names.append(current.name)
+ current = current.parent_id
+ res.append((category.id, ' / '.join(reversed(names))))
+ return res
+
+ @api.model
+ def name_search(self, name, args=None, operator='ilike', limit=100):
+ args = args or []
+ if name:
+ # Be sure name_search is symetric to name_get
+ name = name.split(' / ')[-1]
+ args = [('name', operator, name)] + args
+ return self.search(args, limit=limit).name_get()
+
+
+class PartnerTitle(models.Model):
+ _name = 'res.partner.title'
+ _order = 'name'
+
+ name = fields.Char(string='Title', required=True, translate=True)
+ shortcut = fields.Char(string='Abbreviation', translate=True)
+
+
+class Partner(models.Model):
+ _description = 'Contact'
+ _inherit = ['format.address.mixin']
+ _name = "res.partner"
+ _order = "display_name"
+
+ def _default_category(self):
+ return self.env['res.partner.category'].browse(self._context.get('category_id'))
+
+ def _default_company(self):
+ return self.env['res.company']._company_default_get('res.partner')
+
+ name = fields.Char(index=True)
+ display_name = fields.Char(compute='_compute_display_name', store=True, index=True)
+ date = fields.Date(index=True)
+ title = fields.Many2one('res.partner.title')
+ parent_id = fields.Many2one('res.partner', string='Related Company', index=True)
+ parent_name = fields.Char(related='parent_id.name', readonly=True, string='Parent name')
+ child_ids = fields.One2many('res.partner', 'parent_id', string='Contacts', domain=[('active', '=', True)]) # force "active_test" domain to bypass _search() override
+ ref = fields.Char(string='Internal Reference', index=True)
+ lang = fields.Selection(_lang_get, string='Language', default=lambda self: self.env.lang,
+ help="If the selected language is loaded in the system, all documents related to "
+ "this contact will be printed in this language. If not, it will be English.")
+ tz = fields.Selection(_tz_get, string='Timezone', default=lambda self: self._context.get('tz'),
+ help="The partner's timezone, used to output proper date and time values "
+ "inside printed reports. It is important to set a value for this field. "
+ "You should use the same timezone that is otherwise used to pick and "
+ "render date and time values: your computer's timezone.")
+ tz_offset = fields.Char(compute='_compute_tz_offset', string='Timezone offset', invisible=True)
+ user_id = fields.Many2one('res.users', string='Salesperson',
+ help='The internal user that is in charge of communicating with this contact if any.')
+ vat = fields.Char(string='TIN', help="Tax Identification Number. "
+ "Fill it if the company is subjected to taxes. "
+ "Used by the some of the legal statements.")
+ bank_ids = fields.One2many('res.partner.bank', 'partner_id', string='Banks')
+ website = fields.Char(help="Website of Partner or Company")
+ comment = fields.Text(string='Notes')
+
+ category_id = fields.Many2many('res.partner.category', column1='partner_id',
+ column2='category_id', string='Tags', default=_default_category)
+ credit_limit = fields.Float(string='Credit Limit')
+ barcode = fields.Char(oldname='ean13')
+ active = fields.Boolean(default=True)
+ customer = fields.Boolean(string='Is a Customer', default=True,
+ help="Check this box if this contact is a customer.")
+ supplier = fields.Boolean(string='Is a Vendor',
+ help="Check this box if this contact is a vendor. "
+ "If it's not checked, purchase people will not see it when encoding a purchase order.")
+ employee = fields.Boolean(help="Check this box if this contact is an Employee.")
+ function = fields.Char(string='Job Position')
+ type = fields.Selection(
+ [('contact', 'Contact'),
+ ('invoice', 'Invoice address'),
+ ('delivery', 'Shipping address'),
+ ('other', 'Other address')], string='Address Type',
+ default='contact',
+ help="Used to select automatically the right address according to the context in sales and purchases documents.")
+ street = fields.Char()
+ street2 = fields.Char()
+ zip = fields.Char(change_default=True)
+ city = fields.Char()
+ state_id = fields.Many2one("res.country.state", string='State', ondelete='restrict')
+ country_id = fields.Many2one('res.country', string='Country', ondelete='restrict')
+ email = fields.Char()
+ email_formatted = fields.Char(
+ 'Formatted Email', compute='_compute_email_formatted',
+ help='Format email address "Name <email@domain>"')
+ phone = fields.Char()
+ mobile = fields.Char()
+ is_company = fields.Boolean(string='Is a Company', default=False,
+ help="Check if the contact is a company, otherwise it is a person")
+ industry_id = fields.Many2one('res.partner.industry', 'Industry')
+ # company_type is only an interface field, do not use it in business logic
+ company_type = fields.Selection(string='Company Type',
+ selection=[('person', 'Individual'), ('company', 'Company')],
+ compute='_compute_company_type', inverse='_write_company_type')
+ company_id = fields.Many2one('res.company', 'Company', index=True, default=_default_company)
+ color = fields.Integer(string='Color Index', default=0)
+ user_ids = fields.One2many('res.users', 'partner_id', string='Users', auto_join=True)
+ partner_share = fields.Boolean(
+ 'Share Partner', compute='_compute_partner_share', store=True,
+ help="Either customer (no user), either shared user. Indicated the current partner is a customer without "
+ "access or with a limited access created for sharing data.")
+ contact_address = fields.Char(compute='_compute_contact_address', string='Complete Address')
+
+ # technical field used for managing commercial fields
+ commercial_partner_id = fields.Many2one('res.partner', compute='_compute_commercial_partner',
+ string='Commercial Entity', store=True, index=True)
+ commercial_partner_country_id = fields.Many2one('res.country', related='commercial_partner_id.country_id', store=True)
+ commercial_company_name = fields.Char('Company Name Entity', compute='_compute_commercial_company_name',
+ store=True)
+ company_name = fields.Char('Company Name')
+
+ # image: all image fields are base64 encoded and PIL-supported
+ image = fields.Binary("Image", attachment=True,
+ help="This field holds the image used as avatar for this contact, limited to 1024x1024px",)
+ image_medium = fields.Binary("Medium-sized image", attachment=True,
+ help="Medium-sized image of this contact. It is automatically "\
+ "resized as a 128x128px image, with aspect ratio preserved. "\
+ "Use this field in form views or some kanban views.")
+ image_small = fields.Binary("Small-sized image", attachment=True,
+ help="Small-sized image of this contact. It is automatically "\
+ "resized as a 64x64px image, with aspect ratio preserved. "\
+ "Use this field anywhere a small image is required.")
+ # hack to allow using plain browse record in qweb views, and used in ir.qweb.field.contact
+ self = fields.Many2one(comodel_name=_name, compute='_compute_get_ids')
+
+ _sql_constraints = [
+ ('check_name', "CHECK( (type='contact' AND name IS NOT NULL) or (type!='contact') )", 'Contacts require a name.'),
+ ]
+
+ @api.depends('is_company', 'name', 'parent_id.name', 'type', 'company_name')
+ def _compute_display_name(self):
+ diff = dict(show_address=None, show_address_only=None, show_email=None)
+ names = dict(self.with_context(**diff).name_get())
+ for partner in self:
+ partner.display_name = names.get(partner.id)
+
+ @api.depends('tz')
+ def _compute_tz_offset(self):
+ for partner in self:
+ partner.tz_offset = datetime.datetime.now(pytz.timezone(partner.tz or 'GMT')).strftime('%z')
+
+ @api.depends('user_ids.share')
+ def _compute_partner_share(self):
+ for partner in self:
+ partner.partner_share = not partner.user_ids or any(user.share for user in partner.user_ids)
+
+ @api.depends(lambda self: self._display_address_depends())
+ def _compute_contact_address(self):
+ for partner in self:
+ partner.contact_address = partner._display_address()
+
+ @api.one
+ def _compute_get_ids(self):
+ self.self = self.id
+
+ @api.depends('is_company', 'parent_id.commercial_partner_id')
+ def _compute_commercial_partner(self):
+ for partner in self:
+ if partner.is_company or not partner.parent_id:
+ partner.commercial_partner_id = partner
+ else:
+ partner.commercial_partner_id = partner.parent_id.commercial_partner_id
+
+ @api.depends('company_name', 'parent_id.is_company', 'commercial_partner_id.name')
+ def _compute_commercial_company_name(self):
+ for partner in self:
+ p = partner.commercial_partner_id
+ partner.commercial_company_name = p.is_company and p.name or partner.company_name
+
+ @api.model
+ def _get_default_image(self, partner_type, is_company, parent_id):
+ if getattr(threading.currentThread(), 'testing', False) or self._context.get('install_mode'):
+ return False
+
+ colorize, img_path, image = False, False, False
+
+ if partner_type in ['other'] and parent_id:
+ parent_image = self.browse(parent_id).image
+ image = parent_image and parent_image.decode('base64') or None
+
+ if not image and partner_type == 'invoice':
+ img_path = get_module_resource('base', 'static/img', 'money.png')
+ elif not image and partner_type == 'delivery':
+ img_path = get_module_resource('base', 'static/img', 'truck.png')
+ elif not image and is_company:
+ img_path = get_module_resource('base', 'static/img', 'company_image.png')
+ elif not image:
+ img_path = get_module_resource('base', 'static/img', 'avatar.png')
+ colorize = True
+
+ if img_path:
+ with open(img_path, 'rb') as f:
+ image = f.read()
+ if image and colorize:
+ image = tools.image_colorize(image)
+
+ return tools.image_resize_image_big(base64.b64encode(image))
+
+ @api.model
+ def _fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False):
+ if (not view_id) and (view_type == 'form') and self._context.get('force_email'):
+ view_id = self.env.ref('base.view_partner_simple_form').id
+ res = super(Partner, self)._fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu)
+ if view_type == 'form':
+ res['arch'] = self._fields_view_get_address(res['arch'])
+ return res
+
+ @api.constrains('parent_id')
+ def _check_parent_id(self):
+ if not self._check_recursion():
+ raise ValidationError(_('You cannot create recursive Partner hierarchies.'))
+
+ @api.multi
+ def copy(self, default=None):
+ self.ensure_one()
+ default = dict(default or {}, name=_('%s (copy)') % self.name)
+ return super(Partner, self).copy(default)
+
+ @api.onchange('parent_id')
+ def onchange_parent_id(self):
+ # return values in result, as this method is used by _fields_sync()
+ if not self.parent_id:
+ return
+ result = {}
+ partner = getattr(self, '_origin', self)
+ if partner.parent_id and partner.parent_id != self.parent_id:
+ result['warning'] = {
+ 'title': _('Warning'),
+ 'message': _('Changing the company of a contact should only be done if it '
+ 'was never correctly set. If an existing contact starts working for a new '
+ 'company then a new contact should be created under that new '
+ 'company. You can use the "Discard" button to abandon this change.')}
+ if partner.type == 'contact' or self.type == 'contact':
+ # for contacts: copy the parent address, if set (aka, at least one
+ # value is set in the address: otherwise, keep the one from the
+ # contact)
+ address_fields = self._address_fields()
+ if any(self.parent_id[key] for key in address_fields):
+ def convert(value):
+ return value.id if isinstance(value, models.BaseModel) else value
+ result['value'] = {key: convert(self.parent_id[key]) for key in address_fields}
+ return result
+
+ @api.onchange('country_id')
+ def _onchange_country_id(self):
+ if self.country_id:
+ return {'domain': {'state_id': [('country_id', '=', self.country_id.id)]}}
+ else:
+ return {'domain': {'state_id': []}}
+
+ @api.onchange('email')
+ def onchange_email(self):
+ if not self.image and self._context.get('gravatar_image') and self.email:
+ self.image = self._get_gravatar_image(self.email)
+
+ @api.depends('name', 'email')
+ def _compute_email_formatted(self):
+ for partner in self:
+ partner.email_formatted = formataddr((partner.name or u"False", partner.email or u"False"))
+
+ @api.depends('is_company')
+ def _compute_company_type(self):
+ for partner in self:
+ partner.company_type = 'company' if partner.is_company else 'person'
+
+ def _write_company_type(self):
+ for partner in self:
+ partner.is_company = partner.company_type == 'company'
+
+ @api.onchange('company_type')
+ def onchange_company_type(self):
+ self.is_company = (self.company_type == 'company')
+
+ @api.multi
+ def _update_fields_values(self, fields):
+ """ Returns dict of write() values for synchronizing ``fields`` """
+ values = {}
+ for fname in fields:
+ field = self._fields[fname]
+ if field.type == 'many2one':
+ values[fname] = self[fname].id
+ elif field.type == 'one2many':
+ raise AssertionError(_('One2Many fields cannot be synchronized as part of `commercial_fields` or `address fields`'))
+ elif field.type == 'many2many':
+ values[fname] = [(6, 0, self[fname].ids)]
+ else:
+ values[fname] = self[fname]
+ return values
+
+ @api.model
+ def _address_fields(self):
+ """Returns the list of address fields that are synced from the parent."""
+ return list(ADDRESS_FIELDS)
+
+ @api.multi
+ def update_address(self, vals):
+ addr_vals = {key: vals[key] for key in self._address_fields() if key in vals}
+ if addr_vals:
+ return super(Partner, self).write(addr_vals)
+
+ @api.model
+ def _commercial_fields(self):
+ """ Returns the list of fields that are managed by the commercial entity
+ to which a partner belongs. These fields are meant to be hidden on
+ partners that aren't `commercial entities` themselves, and will be
+ delegated to the parent `commercial entity`. The list is meant to be
+ extended by inheriting classes. """
+ return ['vat', 'credit_limit']
+
+ @api.multi
+ def _commercial_sync_from_company(self):
+ """ Handle sync of commercial fields when a new parent commercial entity is set,
+ as if they were related fields """
+ commercial_partner = self.commercial_partner_id
+ if commercial_partner != self:
+ sync_vals = commercial_partner._update_fields_values(self._commercial_fields())
+ self.write(sync_vals)
+
+ @api.multi
+ def _commercial_sync_to_children(self):
+ """ Handle sync of commercial fields to descendants """
+ commercial_partner = self.commercial_partner_id
+ sync_vals = commercial_partner._update_fields_values(self._commercial_fields())
+ sync_children = self.child_ids.filtered(lambda c: not c.is_company)
+ for child in sync_children:
+ child._commercial_sync_to_children()
+ sync_children._compute_commercial_partner()
+ return sync_children.write(sync_vals)
+
+ @api.multi
+ def _fields_sync(self, values):
+ """ Sync commercial fields and address fields from company and to children after create/update,
+ just as if those were all modeled as fields.related to the parent """
+ # 1. From UPSTREAM: sync from parent
+ if values.get('parent_id') or values.get('type', 'contact'):
+ # 1a. Commercial fields: sync if parent changed
+ if values.get('parent_id'):
+ self._commercial_sync_from_company()
+ # 1b. Address fields: sync if parent or use_parent changed *and* both are now set
+ if self.parent_id and self.type == 'contact':
+ onchange_vals = self.onchange_parent_id().get('value', {})
+ self.update_address(onchange_vals)
+
+ # 2. To DOWNSTREAM: sync children
+ if self.child_ids:
+ # 2a. Commercial Fields: sync if commercial entity
+ if self.commercial_partner_id == self:
+ commercial_fields = self._commercial_fields()
+ if any(field in values for field in commercial_fields):
+ self._commercial_sync_to_children()
+ for child in self.child_ids.filtered(lambda c: not c.is_company):
+ if child.commercial_partner_id != self.commercial_partner_id :
+ self._commercial_sync_to_children()
+ break
+ # 2b. Address fields: sync if address changed
+ address_fields = self._address_fields()
+ if any(field in values for field in address_fields):
+ contacts = self.child_ids.filtered(lambda c: c.type == 'contact')
+ contacts.update_address(values)
+
+ @api.multi
+ def _handle_first_contact_creation(self):
+ """ On creation of first contact for a company (or root) that has no address, assume contact address
+ was meant to be company address """
+ parent = self.parent_id
+ address_fields = self._address_fields()
+ if (parent.is_company or not parent.parent_id) and len(parent.child_ids) == 1 and \
+ any(self[f] for f in address_fields) and not any(parent[f] for f in address_fields):
+ addr_vals = self._update_fields_values(address_fields)
+ parent.update_address(addr_vals)
+
+ def _clean_website(self, website):
+ url = urls.url_parse(website)
+ if not url.scheme:
+ if not url.netloc:
+ url = url.replace(netloc=url.path, path='')
+ website = url.replace(scheme='http').to_url()
+ return website
+
+ @api.multi
+ def write(self, vals):
+ # res.partner must only allow to set the company_id of a partner if it
+ # is the same as the company of all users that inherit from this partner
+ # (this is to allow the code from res_users to write to the partner!) or
+ # if setting the company_id to False (this is compatible with any user
+ # company)
+ if vals.get('website'):
+ vals['website'] = self._clean_website(vals['website'])
+ if vals.get('parent_id'):
+ vals['company_name'] = False
+ if vals.get('company_id'):
+ company = self.env['res.company'].browse(vals['company_id'])
+ for partner in self:
+ if partner.user_ids:
+ companies = set(user.company_id for user in partner.user_ids)
+ if len(companies) > 1 or company not in companies:
+ raise UserError(_("You can not change the company as the partner/user has multiple user linked with different companies."))
+ tools.image_resize_images(vals)
+
+ result = True
+ # To write in SUPERUSER on field is_company and avoid access rights problems.
+ if 'is_company' in vals and self.user_has_groups('base.group_partner_manager') and not self.env.uid == SUPERUSER_ID:
+ result = super(Partner, self).sudo().write({'is_company': vals.get('is_company')})
+ del vals['is_company']
+ result = result and super(Partner, self).write(vals)
+ for partner in self:
+ if any(u.has_group('base.group_user') for u in partner.user_ids if u != self.env.user):
+ self.env['res.users'].check_access_rights('write')
+ partner._fields_sync(vals)
+ return result
+
+ @api.model
+ def create(self, vals):
+ if vals.get('website'):
+ vals['website'] = self._clean_website(vals['website'])
+ if vals.get('parent_id'):
+ vals['company_name'] = False
+ # compute default image in create, because computing gravatar in the onchange
+ # cannot be easily performed if default images are in the way
+ if not vals.get('image'):
+ vals['image'] = self._get_default_image(vals.get('type'), vals.get('is_company'), vals.get('parent_id'))
+ tools.image_resize_images(vals)
+ partner = super(Partner, self).create(vals)
+ partner._fields_sync(vals)
+ partner._handle_first_contact_creation()
+ return partner
+
+ @api.multi
+ def create_company(self):
+ self.ensure_one()
+ if self.company_name:
+ # Create parent company
+ values = dict(name=self.company_name, is_company=True)
+ values.update(self._update_fields_values(self._address_fields()))
+ new_company = self.create(values)
+ # Set new company as my parent
+ self.write({
+ 'parent_id': new_company.id,
+ 'child_ids': [(1, partner_id, dict(parent_id=new_company.id)) for partner_id in self.child_ids.ids]
+ })
+ return True
+
+ @api.multi
+ def open_commercial_entity(self):
+ """ Utility method used to add an "Open Company" button in partner views """
+ self.ensure_one()
+ return {'type': 'ir.actions.act_window',
+ 'res_model': 'res.partner',
+ 'view_mode': 'form',
+ 'res_id': self.commercial_partner_id.id,
+ 'target': 'current',
+ 'flags': {'form': {'action_buttons': True}}}
+
+ @api.multi
+ def open_parent(self):
+ """ Utility method used to add an "Open Parent" button in partner views """
+ self.ensure_one()
+ address_form_id = self.env.ref('base.view_partner_address_form').id
+ return {'type': 'ir.actions.act_window',
+ 'res_model': 'res.partner',
+ 'view_mode': 'form',
+ 'views': [(address_form_id, 'form')],
+ 'res_id': self.parent_id.id,
+ 'target': 'new',
+ 'flags': {'form': {'action_buttons': True}}}
+
+ @api.multi
+ def name_get(self):
+ res = []
+ for partner in self:
+ name = partner.name or ''
+
+ if partner.company_name or partner.parent_id:
+ if not name and partner.type in ['invoice', 'delivery', 'other']:
+ name = dict(self.fields_get(['type'])['type']['selection'])[partner.type]
+ if not partner.is_company:
+ name = "%s, %s" % (partner.commercial_company_name or partner.parent_id.name, name)
+ if self._context.get('show_address_only'):
+ name = partner._display_address(without_company=True)
+ if self._context.get('show_address'):
+ name = name + "\n" + partner._display_address(without_company=True)
+ name = name.replace('\n\n', '\n')
+ name = name.replace('\n\n', '\n')
+ if self._context.get('show_email') and partner.email:
+ name = "%s <%s>" % (name, partner.email)
+ if self._context.get('html_format'):
+ name = name.replace('\n', '<br/>')
+ res.append((partner.id, name))
+ return res
+
+ def _parse_partner_name(self, text, context=None):
+ """ Supported syntax:
+ - 'Raoul <raoul@grosbedon.fr>': will find name and email address
+ - otherwise: default, everything is set as the name """
+ emails = tools.email_split(text.replace(' ', ','))
+ if emails:
+ email = emails[0]
+ name = text[:text.index(email)].replace('"', '').replace('<', '').strip()
+ else:
+ name, email = text, ''
+ return name, email
+
+ @api.model
+ def name_create(self, name):
+ """ Override of orm's name_create method for partners. The purpose is
+ to handle some basic formats to create partners using the
+ name_create.
+ If only an email address is received and that the regex cannot find
+ a name, the name will have the email value.
+ If 'force_email' key in context: must find the email address. """
+ name, email = self._parse_partner_name(name)
+ if self._context.get('force_email') and not email:
+ raise UserError(_("Couldn't create contact without email address!"))
+ if not name and email:
+ name = email
+ partner = self.create({self._rec_name: name or email, 'email': email or self.env.context.get('default_email', False)})
+ return partner.name_get()[0]
+
+ @api.model
+ def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None):
+ """ Override search() to always show inactive children when searching via ``child_of`` operator. The ORM will
+ always call search() with a simple domain of the form [('parent_id', 'in', [ids])]. """
+ # a special ``domain`` is set on the ``child_ids`` o2m to bypass this logic, as it uses similar domain expressions
+ if len(args) == 1 and len(args[0]) == 3 and args[0][:2] == ('parent_id','in') \
+ and args[0][2] != [False]:
+ self = self.with_context(active_test=False)
+ return super(Partner, self)._search(args, offset=offset, limit=limit, order=order,
+ count=count, access_rights_uid=access_rights_uid)
+
+ @api.model
+ def name_search(self, name, args=None, operator='ilike', limit=100):
+ if args is None:
+ args = []
+ if name and operator in ('=', 'ilike', '=ilike', 'like', '=like'):
+ self.check_access_rights('read')
+ where_query = self._where_calc(args)
+ self._apply_ir_rules(where_query, 'read')
+ from_clause, where_clause, where_clause_params = where_query.get_sql()
+ where_str = where_clause and (" WHERE %s AND " % where_clause) or ' WHERE '
+
+ # search on the name of the contacts and of its company
+ search_name = name
+ if operator in ('ilike', 'like'):
+ search_name = '%%%s%%' % name
+ if operator in ('=ilike', '=like'):
+ operator = operator[1:]
+
+ unaccent = get_unaccent_wrapper(self.env.cr)
+
+ query = """SELECT id
+ FROM res_partner
+ {where} ({email} {operator} {percent}
+ OR {display_name} {operator} {percent}
+ OR {reference} {operator} {percent}
+ OR {vat} {operator} {percent})
+ -- don't panic, trust postgres bitmap
+ ORDER BY {display_name} {operator} {percent} desc,
+ {display_name}
+ """.format(where=where_str,
+ operator=operator,
+ email=unaccent('email'),
+ display_name=unaccent('display_name'),
+ reference=unaccent('ref'),
+ percent=unaccent('%s'),
+ vat=unaccent('vat'),)
+
+ where_clause_params += [search_name]*5
+ if limit:
+ query += ' limit %s'
+ where_clause_params.append(limit)
+ self.env.cr.execute(query, where_clause_params)
+ partner_ids = [row[0] for row in self.env.cr.fetchall()]
+
+ if partner_ids:
+ return self.browse(partner_ids).name_get()
+ else:
+ return []
+ return super(Partner, self).name_search(name, args, operator=operator, limit=limit)
+
+ @api.model
+ def find_or_create(self, email):
+ """ Find a partner with the given ``email`` or use :py:method:`~.name_create`
+ to create one
+
+ :param str email: email-like string, which should contain at least one email,
+ e.g. ``"Raoul Grosbedon <r.g@grosbedon.fr>"``"""
+ assert email, 'an email is required for find_or_create to work'
+ emails = tools.email_split(email)
+ if emails:
+ email = emails[0]
+ partners = self.search([('email', '=ilike', email)], limit=1)
+ return partners.id or self.name_create(email)[0]
+
+ def _get_gravatar_image(self, email):
+ email_hash = hashlib.md5(email.lower().encode('utf-8')).hexdigest()
+ url = "https://www.gravatar.com/avatar/" + email_hash
+ try:
+ res = requests.get(url, params={'d': '404', 's': '128'}, timeout=5)
+ if res.status_code != requests.codes.ok:
+ return False
+ except requests.exceptions.ConnectionError as e:
+ return False
+ return base64.b64encode(res.content)
+
+ @api.multi
+ def _email_send(self, email_from, subject, body, on_error=None):
+ for partner in self.filtered('email'):
+ tools.email_send(email_from, [partner.email], subject, body, on_error)
+ return True
+
+ @api.multi
+ def address_get(self, adr_pref=None):
+ """ Find contacts/addresses of the right type(s) by doing a depth-first-search
+ through descendants within company boundaries (stop at entities flagged ``is_company``)
+ then continuing the search at the ancestors that are within the same company boundaries.
+ Defaults to partners of type ``'default'`` when the exact type is not found, or to the
+ provided partner itself if no type ``'default'`` is found either. """
+ adr_pref = set(adr_pref or [])
+ if 'contact' not in adr_pref:
+ adr_pref.add('contact')
+ result = {}
+ visited = set()
+ for partner in self:
+ current_partner = partner
+ while current_partner:
+ to_scan = [current_partner]
+ # Scan descendants, DFS
+ while to_scan:
+ record = to_scan.pop(0)
+ visited.add(record)
+ if record.type in adr_pref and not result.get(record.type):
+ result[record.type] = record.id
+ if len(result) == len(adr_pref):
+ return result
+ to_scan = [c for c in record.child_ids
+ if c not in visited
+ if not c.is_company] + to_scan
+
+ # Continue scanning at ancestor if current_partner is not a commercial entity
+ if current_partner.is_company or not current_partner.parent_id:
+ break
+ current_partner = current_partner.parent_id
+
+ # default to type 'contact' or the partner itself
+ default = result.get('contact', self.id or False)
+ for adr_type in adr_pref:
+ result[adr_type] = result.get(adr_type) or default
+ return result
+
+ @api.model
+ def view_header_get(self, view_id, view_type):
+ res = super(Partner, self).view_header_get(view_id, view_type)
+ if res: return res
+ if not self._context.get('category_id'):
+ return False
+ return _('Partners: ') + self.env['res.partner.category'].browse(self._context['category_id']).name
+
+ @api.model
+ @api.returns('self')
+ def main_partner(self):
+ ''' Return the main partner '''
+ return self.env.ref('base.main_partner')
+
+ @api.model
+ def _get_default_address_format(self):
+ return "%(street)s\n%(street2)s\n%(city)s %(state_code)s %(zip)s\n%(country_name)s"
+
+ @api.multi
+ def _display_address(self, without_company=False):
+
+ '''
+ The purpose of this function is to build and return an address formatted accordingly to the
+ standards of the country where it belongs.
+
+ :param address: browse record of the res.partner to format
+ :returns: the address formatted in a display that fit its country habits (or the default ones
+ if not country is specified)
+ :rtype: string
+ '''
+ # get the information that will be injected into the display format
+ # get the address format
+ address_format = self.country_id.address_format or \
+ self._get_default_address_format()
+ args = {
+ 'state_code': self.state_id.code or '',
+ 'state_name': self.state_id.name or '',
+ 'country_code': self.country_id.code or '',
+ 'country_name': self.country_id.name or '',
+ 'company_name': self.commercial_company_name or '',
+ }
+ for field in self._address_fields():
+ args[field] = getattr(self, field) or ''
+ if without_company:
+ args['company_name'] = ''
+ elif self.commercial_company_name:
+ address_format = '%(company_name)s\n' + address_format
+ return address_format % args
+
+ def _display_address_depends(self):
+ # field dependencies of method _display_address()
+ return self._address_fields() + [
+ 'country_id.address_format', 'country_id.code', 'country_id.name',
+ 'company_name', 'state_id.code', 'state_id.name',
+ ]
+
+
+class ResPartnerIndustry(models.Model):
+ _description = 'Industry'
+ _name = "res.partner.industry"
+ _order = "name"
+
+ name = fields.Char('Name', translate=True)
+ full_name = fields.Char('Full Name', translate=True)
+ active = fields.Boolean('Active', default=True)
diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py
new file mode 100644
index 00000000000..5f136959014
--- /dev/null
+++ b/odoo/addons/base/models/res_users.py
@@ -0,0 +1,957 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import pytz
+import datetime
+import itertools
+import logging
+
+from collections import defaultdict
+from itertools import chain, repeat
+from lxml import etree
+from lxml.builder import E
+
+from odoo import api, fields, models, tools, SUPERUSER_ID, _
+from odoo.exceptions import AccessDenied, AccessError, UserError, ValidationError
+from odoo.osv import expression
+from odoo.service.db import check_super
+from odoo.tools import partition, pycompat
+
+_logger = logging.getLogger(__name__)
+
+# Only users who can modify the user (incl. the user herself) see the real contents of these fields
+USER_PRIVATE_FIELDS = ['password']
+
+concat = chain.from_iterable
+
+#
+# Functions for manipulating boolean and selection pseudo-fields
+#
+def name_boolean_group(id):
+ return 'in_group_' + str(id)
+
+def name_selection_groups(ids):
+ return 'sel_groups_' + '_'.join(str(it) for it in ids)
+
+def is_boolean_group(name):
+ return name.startswith('in_group_')
+
+def is_selection_groups(name):
+ return name.startswith('sel_groups_')
+
+def is_reified_group(name):
+ return is_boolean_group(name) or is_selection_groups(name)
+
+def get_boolean_group(name):
+ return int(name[9:])
+
+def get_selection_groups(name):
+ return [int(v) for v in name[11:].split('_')]
+
+def parse_m2m(commands):
+ "return a list of ids corresponding to a many2many value"
+ ids = []
+ for command in commands:
+ if isinstance(command, (tuple, list)):
+ if command[0] in (1, 4):
+ ids.append(command[1])
+ elif command[0] == 5:
+ ids = []
+ elif command[0] == 6:
+ ids = list(command[2])
+ else:
+ ids.append(command)
+ return ids
+
+#----------------------------------------------------------
+# Basic res.groups and res.users
+#----------------------------------------------------------
+
+class Groups(models.Model):
+ _name = "res.groups"
+ _description = "Access Groups"
+ _rec_name = 'full_name'
+ _order = 'name'
+
+ name = fields.Char(required=True, translate=True)
+ users = fields.Many2many('res.users', 'res_groups_users_rel', 'gid', 'uid')
+ model_access = fields.One2many('ir.model.access', 'group_id', string='Access Controls', copy=True)
+ rule_groups = fields.Many2many('ir.rule', 'rule_group_rel',
+ 'group_id', 'rule_group_id', string='Rules', domain=[('global', '=', False)])
+ menu_access = fields.Many2many('ir.ui.menu', 'ir_ui_menu_group_rel', 'gid', 'menu_id', string='Access Menu')
+ view_access = fields.Many2many('ir.ui.view', 'ir_ui_view_group_rel', 'group_id', 'view_id', string='Views')
+ comment = fields.Text(translate=True)
+ category_id = fields.Many2one('ir.module.category', string='Application', index=True)
+ color = fields.Integer(string='Color Index')
+ full_name = fields.Char(compute='_compute_full_name', string='Group Name', search='_search_full_name')
+ share = fields.Boolean(string='Share Group', help="Group created to set access rights for sharing data with some users.")
+ is_portal = fields.Boolean('Portal', help="If checked, this group is usable as a portal.")
+
+ _sql_constraints = [
+ ('name_uniq', 'unique (category_id, name)', 'The name of the group must be unique within an application!')
+ ]
+
+ @api.depends('category_id.name', 'name')
+ def _compute_full_name(self):
+ # Important: value must be stored in environment of group, not group1!
+ for group, group1 in pycompat.izip(self, self.sudo()):
+ if group1.category_id:
+ group.full_name = '%s / %s' % (group1.category_id.name, group1.name)
+ else:
+ group.full_name = group1.name
+
+ def _search_full_name(self, operator, operand):
+ lst = True
+ if isinstance(operand, bool):
+ domains = [[('name', operator, operand)], [('category_id.name', operator, operand)]]
+ if operator in expression.NEGATIVE_TERM_OPERATORS == (not operand):
+ return expression.AND(domains)
+ else:
+ return expression.OR(domains)
+ if isinstance(operand, pycompat.string_types):
+ lst = False
+ operand = [operand]
+ where = []
+ for group in operand:
+ values = [v for v in group.split('/') if v]
+ group_name = values.pop().strip()
+ category_name = values and '/'.join(values).strip() or group_name
+ group_domain = [('name', operator, lst and [group_name] or group_name)]
+ category_domain = [('category_id.name', operator, lst and [category_name] or category_name)]
+ if operator in expression.NEGATIVE_TERM_OPERATORS and not values:
+ category_domain = expression.OR([category_domain, [('category_id', '=', False)]])
+ if (operator in expression.NEGATIVE_TERM_OPERATORS) == (not values):
+ sub_where = expression.AND([group_domain, category_domain])
+ else:
+ sub_where = expression.OR([group_domain, category_domain])
+ if operator in expression.NEGATIVE_TERM_OPERATORS:
+ where = expression.AND([where, sub_where])
+ else:
+ where = expression.OR([where, sub_where])
+ return where
+
+ @api.model
+ def search(self, args, offset=0, limit=None, order=None, count=False):
+ # add explicit ordering if search is sorted on full_name
+ if order and order.startswith('full_name'):
+ groups = super(Groups, self).search(args)
+ groups = groups.sorted('full_name', reverse=order.endswith('DESC'))
+ groups = groups[offset:offset+limit] if limit else groups[offset:]
+ return len(groups) if count else groups.ids
+ return super(Groups, self).search(args, offset=offset, limit=limit, order=order, count=count)
+
+ @api.multi
+ def copy(self, default=None):
+ self.ensure_one()
+ default = dict(default or {}, name=_('%s (copy)') % self.name)
+ return super(Groups, self).copy(default)
+
+ @api.multi
+ def write(self, vals):
+ if 'name' in vals:
+ if vals['name'].startswith('-'):
+ raise UserError(_('The name of the group can not start with "-"'))
+ # invalidate caches before updating groups, since the recomputation of
+ # field 'share' depends on method has_group()
+ self.env['ir.model.access'].call_cache_clearing_methods()
+ self.env['res.users'].has_group.clear_cache(self.env['res.users'])
+ return super(Groups, self).write(vals)
+
+
+class ResUsersLog(models.Model):
+ _name = 'res.users.log'
+ _order = 'id desc'
+ # Currenly only uses the magical fields: create_uid, create_date,
+ # for recording logins. To be extended for other uses (chat presence, etc.)
+
+
+class Users(models.Model):
+ """ User class. A res.users record models an OpenERP user and is different
+ from an employee.
+
+ res.users class now inherits from res.partner. The partner model is
+ used to store the data related to the partner: lang, name, address,
+ avatar, ... The user model is now dedicated to technical data.
+ """
+ _name = "res.users"
+ _description = 'Users'
+ _inherits = {'res.partner': 'partner_id'}
+ _order = 'name, login'
+ __uid_cache = defaultdict(dict) # {dbname: {uid: password}}
+
+ # User can write on a few of his own fields (but not his groups for example)
+ SELF_WRITEABLE_FIELDS = ['signature', 'action_id', 'company_id', 'email', 'name', 'image', 'image_medium', 'image_small', 'lang', 'tz']
+ # User can read a few of his own fields
+ SELF_READABLE_FIELDS = ['signature', 'company_id', 'login', 'email', 'name', 'image', 'image_medium', 'image_small', 'lang', 'tz', 'tz_offset', 'groups_id', 'partner_id', '__last_update', 'action_id']
+
+ def _default_groups(self):
+ default_user = self.env.ref('base.default_user', raise_if_not_found=False)
+ return (default_user or self.env['res.users']).sudo().groups_id
+
+ def _companies_count(self):
+ return self.env['res.company'].sudo().search_count([])
+
+ partner_id = fields.Many2one('res.partner', required=True, ondelete='restrict', auto_join=True,
+ string='Related Partner', help='Partner-related data of the user')
+ login = fields.Char(required=True, help="Used to log into the system")
+ password = fields.Char(default='', invisible=True, copy=False,
+ help="Keep empty if you don't want the user to be able to connect on the system.")
+ new_password = fields.Char(string='Set Password',
+ compute='_compute_password', inverse='_inverse_password',
+ help="Specify a value only when creating a user or if you're "\
+ "changing the user's password, otherwise leave empty. After "\
+ "a change of password, the user has to login again.")
+ signature = fields.Html()
+ active = fields.Boolean(default=True)
+ action_id = fields.Many2one('ir.actions.actions', string='Home Action',
+ help="If specified, this action will be opened at log on for this user, in addition to the standard menu.")
+ groups_id = fields.Many2many('res.groups', 'res_groups_users_rel', 'uid', 'gid', string='Groups', default=_default_groups)
+ log_ids = fields.One2many('res.users.log', 'create_uid', string='User log entries')
+ login_date = fields.Datetime(related='log_ids.create_date', string='Latest connection')
+ share = fields.Boolean(compute='_compute_share', compute_sudo=True, string='Share User', store=True,
+ help="External user with limited access, created only for the purpose of sharing data.")
+ companies_count = fields.Integer(compute='_compute_companies_count', string="Number of Companies", default=_companies_count)
+ tz_offset = fields.Char(compute='_compute_tz_offset', string='Timezone offset', invisible=True)
+
+ @api.model
+ def _get_company(self):
+ return self.env.user.company_id
+
+ # Special behavior for this field: res.company.search() will only return the companies
+ # available to the current user (should be the user's companies?), when the user_preference
+ # context is set.
+ company_id = fields.Many2one('res.company', string='Company', required=True, default=_get_company,
+ help='The company this user is currently working for.', context={'user_preference': True})
+ company_ids = fields.Many2many('res.company', 'res_company_users_rel', 'user_id', 'cid',
+ string='Companies', default=_get_company)
+
+ # overridden inherited fields to bypass access rights, in case you have
+ # access to the user but not its corresponding partner
+ name = fields.Char(related='partner_id.name', inherited=True)
+ email = fields.Char(related='partner_id.email', inherited=True)
+
+ _sql_constraints = [
+ ('login_key', 'UNIQUE (login)', 'You can not have two users with the same login !')
+ ]
+
+ def _compute_password(self):
+ for user in self:
+ user.password = ''
+
+ def _inverse_password(self):
+ for user in self:
+ if not user.new_password:
+ # Do not update the password if no value is provided, ignore silently.
+ # For example web client submits False values for all empty fields.
+ continue
+ if user == self.env.user:
+ # To change their own password, users must use the client-specific change password wizard,
+ # so that the new password is immediately used for further RPC requests, otherwise the user
+ # will face unexpected 'Access Denied' exceptions.
+ raise UserError(_('Please use the change password wizard (in User Preferences or User menu) to change your own password.'))
+ else:
+ user.password = user.new_password
+
+ @api.depends('groups_id')
+ def _compute_share(self):
+ for user in self:
+ user.share = not user.has_group('base.group_user')
+
+ @api.multi
+ def _compute_companies_count(self):
+ companies_count = self._companies_count()
+ for user in self:
+ user.companies_count = companies_count
+
+ @api.depends('tz')
+ def _compute_tz_offset(self):
+ for user in self:
+ user.tz_offset = datetime.datetime.now(pytz.timezone(user.tz or 'GMT')).strftime('%z')
+
+ @api.onchange('login')
+ def on_change_login(self):
+ if self.login and tools.single_email_re.match(self.login):
+ self.email = self.login
+
+ @api.onchange('parent_id')
+ def onchange_parent_id(self):
+ return self.mapped('partner_id').onchange_parent_id()
+
+ @api.multi
+ @api.constrains('company_id', 'company_ids')
+ def _check_company(self):
+ if any(user.company_ids and user.company_id not in user.company_ids for user in self):
+ raise ValidationError(_('The chosen company is not in the allowed companies for this user'))
+
+ @api.multi
+ def read(self, fields=None, load='_classic_read'):
+ if fields and self == self.env.user:
+ for key in fields:
+ if not (key in self.SELF_READABLE_FIELDS or key.startswith('context_')):
+ break
+ else:
+ # safe fields only, so we read as super-user to bypass access rights
+ self = self.sudo()
+
+ result = super(Users, self).read(fields=fields, load=load)
+
+ canwrite = self.env['ir.model.access'].check('res.users', 'write', False)
+ if not canwrite:
+ for vals in result:
+ if vals['id'] != self._uid:
+ for key in USER_PRIVATE_FIELDS:
+ if key in vals:
+ vals[key] = '********'
+
+ return result
+
+ @api.model
+ def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
+ groupby_fields = set([groupby] if isinstance(groupby, pycompat.string_types) else groupby)
+ if groupby_fields.intersection(USER_PRIVATE_FIELDS):
+ raise AccessError(_("Invalid 'group by' parameter"))
+ return super(Users, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
+
+ @api.model
+ def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None):
+ if self._uid != SUPERUSER_ID and args:
+ domain_fields = {term[0] for term in args if isinstance(term, (tuple, list))}
+ if domain_fields.intersection(USER_PRIVATE_FIELDS):
+ raise AccessError(_('Invalid search criterion'))
+ return super(Users, self)._search(args, offset=offset, limit=limit, order=order, count=count,
+ access_rights_uid=access_rights_uid)
+
+ @api.model
+ def create(self, vals):
+ user = super(Users, self).create(vals)
+ user.partner_id.active = user.active
+ if user.partner_id.company_id:
+ user.partner_id.write({'company_id': user.company_id.id})
+ return user
+
+ @api.multi
+ def write(self, values):
+ if values.get('active') == False:
+ for user in self:
+ if user.id == SUPERUSER_ID:
+ raise UserError(_("You cannot deactivate the admin user."))
+ elif user.id == self._uid:
+ raise UserError(_("You cannot deactivate the user you're currently logged in as."))
+
+ if self == self.env.user:
+ for key in list(values):
+ if not (key in self.SELF_WRITEABLE_FIELDS or key.startswith('context_')):
+ break
+ else:
+ if 'company_id' in values:
+ if values['company_id'] not in self.env.user.company_ids.ids:
+ del values['company_id']
+ # safe fields only, so we write as super-user to bypass access rights
+ self = self.sudo()
+
+ res = super(Users, self).write(values)
+ if 'company_id' in values:
+ for user in self:
+ # if partner is global we keep it that way
+ if user.partner_id.company_id and user.partner_id.company_id.id != values['company_id']:
+ user.partner_id.write({'company_id': user.company_id.id})
+ # clear default ir values when company changes
+ self.env['ir.default'].clear_caches()
+
+ # clear caches linked to the users
+ if 'groups_id' in values:
+ self.env['ir.model.access'].call_cache_clearing_methods()
+ self.env['ir.rule'].clear_caches()
+ self.has_group.clear_cache(self)
+ if any(key.startswith('context_') or key in ('lang', 'tz') for key in values):
+ self.context_get.clear_cache(self)
+ if any(key in values for key in ['active'] + USER_PRIVATE_FIELDS):
+ db = self._cr.dbname
+ for id in self.ids:
+ self.__uid_cache[db].pop(id, None)
+
+ return res
+
+ @api.multi
+ def unlink(self):
+ if SUPERUSER_ID in self.ids:
+ raise UserError(_('You can not remove the admin user as it is used internally for resources created by Odoo (updates, module installation, ...)'))
+ db = self._cr.dbname
+ for id in self.ids:
+ self.__uid_cache[db].pop(id, None)
+ return super(Users, self).unlink()
+
+ @api.model
+ def name_search(self, name='', args=None, operator='ilike', limit=100):
+ if args is None:
+ args = []
+ users = self.browse()
+ if name and operator in ['=', 'ilike']:
+ users = self.search([('login', '=', name)] + args, limit=limit)
+ if not users:
+ users = self.search([('name', operator, name)] + args, limit=limit)
+ return users.name_get()
+
+ @api.multi
+ def copy(self, default=None):
+ self.ensure_one()
+ default = dict(default or {})
+ if ('name' not in default) and ('partner_id' not in default):
+ default['name'] = _("%s (copy)") % self.name
+ if 'login' not in default:
+ default['login'] = _("%s (copy)") % self.login
+ return super(Users, self).copy(default)
+
+ @api.model
+ @tools.ormcache('self._uid')
+ def context_get(self):
+ user = self.env.user
+ result = {}
+ for k in self._fields:
+ if k.startswith('context_'):
+ context_key = k[8:]
+ elif k in ['lang', 'tz']:
+ context_key = k
+ else:
+ context_key = False
+ if context_key:
+ res = getattr(user, k) or False
+ if isinstance(res, models.BaseModel):
+ res = res.id
+ result[context_key] = res or False
+ return result
+
+ @api.model
+ @api.returns('ir.actions.act_window', lambda record: record.id)
+ def action_get(self):
+ return self.sudo().env.ref('base.action_res_users_my')
+
+ def check_super(self, passwd):
+ return check_super(passwd)
+
+ @api.model
+ def check_credentials(self, password):
+ """ Override this method to plug additional authentication methods"""
+ user = self.sudo().search([('id', '=', self._uid), ('password', '=', password)])
+ if not user:
+ raise AccessDenied()
+
+ @api.model
+ def _update_last_login(self):
+ # only create new records to avoid any side-effect on concurrent transactions
+ # extra records will be deleted by the periodical garbage collection
+ self.env['res.users.log'].create({}) # populated by defaults
+
+ @classmethod
+ def _login(cls, db, login, password):
+ if not password:
+ return False
+ user_id = False
+ try:
+ with cls.pool.cursor() as cr:
+ self = api.Environment(cr, SUPERUSER_ID, {})[cls._name]
+ user = self.search([('login', '=', login)])
+ if user:
+ user_id = user.id
+ user.sudo(user_id).check_credentials(password)
+ user.sudo(user_id)._update_last_login()
+ except AccessDenied:
+ _logger.info("Login failed for db:%s login:%s", db, login)
+ user_id = False
+ return user_id
+
+ @classmethod
+ def authenticate(cls, db, login, password, user_agent_env):
+ """Verifies and returns the user ID corresponding to the given
+ ``login`` and ``password`` combination, or False if there was
+ no matching user.
+ :param str db: the database on which user is trying to authenticate
+ :param str login: username
+ :param str password: user password
+ :param dict user_agent_env: environment dictionary describing any
+ relevant environment attributes
+ """
+ uid = cls._login(db, login, password)
+ if uid == SUPERUSER_ID:
+ # Successfully logged in as admin!
+ # Attempt to guess the web base url...
+ if user_agent_env and user_agent_env.get('base_location'):
+ try:
+ with cls.pool.cursor() as cr:
+ base = user_agent_env['base_location']
+ ICP = api.Environment(cr, uid, {})['ir.config_parameter']
+ if not ICP.get_param('web.base.url.freeze'):
+ ICP.set_param('web.base.url', base)
+ except Exception:
+ _logger.exception("Failed to update web.base.url configuration parameter")
+ return uid
+
+ @classmethod
+ def check(cls, db, uid, passwd):
+ """Verifies that the given (uid, password) is authorized for the database ``db`` and
+ raise an exception if it is not."""
+ if not passwd:
+ # empty passwords disallowed for obvious security reasons
+ raise AccessDenied()
+ db = cls.pool.db_name
+ if cls.__uid_cache[db].get(uid) == passwd:
+ return
+ cr = cls.pool.cursor()
+ try:
+ self = api.Environment(cr, uid, {})[cls._name]
+ self.check_credentials(passwd)
+ cls.__uid_cache[db][uid] = passwd
+ finally:
+ cr.close()
+
+ @api.model
+ def change_password(self, old_passwd, new_passwd):
+ """Change current user password. Old password must be provided explicitly
+ to prevent hijacking an existing user session, or for cases where the cleartext
+ password is not used to authenticate requests.
+
+ :return: True
+ :raise: odoo.exceptions.AccessDenied when old password is wrong
+ :raise: odoo.exceptions.UserError when new password is not set or empty
+ """
+ self.check(self._cr.dbname, self._uid, old_passwd)
+ if new_passwd:
+ # use self.env.user here, because it has uid=SUPERUSER_ID
+ return self.env.user.write({'password': new_passwd})
+ raise UserError(_("Setting empty passwords is not allowed for security reasons!"))
+
+ @api.multi
+ def preference_save(self):
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'reload_context',
+ }
+
+ @api.multi
+ def preference_change_password(self):
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'change_password',
+ 'target': 'new',
+ }
+
+ @api.model
+ def has_group(self, group_ext_id):
+ # use singleton's id if called on a non-empty recordset, otherwise
+ # context uid
+ uid = self.id or self._uid
+ return self.sudo(user=uid)._has_group(group_ext_id)
+
+ @api.model
+ @tools.ormcache('self._uid', 'group_ext_id')
+ def _has_group(self, group_ext_id):
+ """Checks whether user belongs to given group.
+
+ :param str group_ext_id: external ID (XML ID) of the group.
+ Must be provided in fully-qualified form (``module.ext_id``), as there
+ is no implicit module to use..
+ :return: True if the current user is a member of the group with the
+ given external ID (XML ID), else False.
+ """
+ assert group_ext_id and '.' in group_ext_id, "External ID must be fully qualified"
+ module, ext_id = group_ext_id.split('.')
+ self._cr.execute("""SELECT 1 FROM res_groups_users_rel WHERE uid=%s AND gid IN
+ (SELECT res_id FROM ir_model_data WHERE module=%s AND name=%s)""",
+ (self._uid, module, ext_id))
+ return bool(self._cr.fetchone())
+ # for a few places explicitly clearing the has_group cache
+ has_group.clear_cache = _has_group.clear_cache
+
+ @api.multi
+ def _is_public(self):
+ self.ensure_one()
+ return self.has_group('base.group_public')
+
+ @api.multi
+ def _is_system(self):
+ self.ensure_one()
+ return self.has_group('base.group_system')
+
+ @api.multi
+ def _is_admin(self):
+ self.ensure_one()
+ return self._is_superuser() or self.has_group('base.group_erp_manager')
+
+ @api.multi
+ def _is_superuser(self):
+ self.ensure_one()
+ return self.id == SUPERUSER_ID
+
+ @api.model
+ def get_company_currency_id(self):
+ return self.env.user.company_id.currency_id.id
+
+#
+# Implied groups
+#
+# Extension of res.groups and res.users with a relation for "implied" or
+# "inherited" groups. Once a user belongs to a group, it automatically belongs
+# to the implied groups (transitively).
+#
+
+class GroupsImplied(models.Model):
+ _inherit = 'res.groups'
+
+ implied_ids = fields.Many2many('res.groups', 'res_groups_implied_rel', 'gid', 'hid',
+ string='Inherits', help='Users of this group automatically inherit those groups')
+ trans_implied_ids = fields.Many2many('res.groups', string='Transitively inherits',
+ compute='_compute_trans_implied')
+
+ @api.depends('implied_ids.trans_implied_ids')
+ def _compute_trans_implied(self):
+ # Compute the transitive closure recursively. Note that the performance
+ # is good, because the record cache behaves as a memo (the field is
+ # never computed twice on a given group.)
+ for g in self:
+ g.trans_implied_ids = g.implied_ids | g.mapped('implied_ids.trans_implied_ids')
+
+ @api.model
+ def create(self, values):
+ user_ids = values.pop('users', None)
+ group = super(GroupsImplied, self).create(values)
+ if user_ids:
+ # delegate addition of users to add implied groups
+ group.write({'users': user_ids})
+ return group
+
+ @api.multi
+ def write(self, values):
+ res = super(GroupsImplied, self).write(values)
+ if values.get('users') or values.get('implied_ids'):
+ # add all implied groups (to all users of each group)
+ for group in self:
+ vals = {'users': list(pycompat.izip(repeat(4), group.with_context(active_test=False).users.ids))}
+ super(GroupsImplied, group.trans_implied_ids).write(vals)
+ return res
+
+
+class UsersImplied(models.Model):
+ _inherit = 'res.users'
+
+ @api.model
+ def create(self, values):
+ if 'groups_id' in values:
+ # complete 'groups_id' with implied groups
+ user = self.new(values)
+ gs = user.groups_id | user.groups_id.mapped('trans_implied_ids')
+ values['groups_id'] = type(self).groups_id.convert_to_write(gs, user.groups_id)
+ return super(UsersImplied, self).create(values)
+
+ @api.multi
+ def write(self, values):
+ res = super(UsersImplied, self).write(values)
+ if values.get('groups_id'):
+ # add implied groups for all users
+ for user in self.with_context({}):
+ gs = set(concat(g.trans_implied_ids for g in user.groups_id))
+ vals = {'groups_id': [(4, g.id) for g in gs]}
+ super(UsersImplied, self).write(vals)
+ return res
+
+#
+# Virtual checkbox and selection for res.user form view
+#
+# Extension of res.groups and res.users for the special groups view in the users
+# form. This extension presents groups with selection and boolean widgets:
+# - Groups are shown by application, with boolean and/or selection fields.
+# Selection fields typically defines a role "Name" for the given application.
+# - Uncategorized groups are presented as boolean fields and grouped in a
+# section "Others".
+#
+# The user form view is modified by an inherited view (base.user_groups_view);
+# the inherited view replaces the field 'groups_id' by a set of reified group
+# fields (boolean or selection fields). The arch of that view is regenerated
+# each time groups are changed.
+#
+# Naming conventions for reified groups fields:
+# - boolean field 'in_group_ID' is True iff
+# ID is in 'groups_id'
+# - selection field 'sel_groups_ID1_..._IDk' is ID iff
+# ID is in 'groups_id' and ID is maximal in the set {ID1, ..., IDk}
+#
+
+class GroupsView(models.Model):
+ _inherit = 'res.groups'
+
+ @api.model
+ def create(self, values):
+ user = super(GroupsView, self).create(values)
+ self._update_user_groups_view()
+ # actions.get_bindings() depends on action records
+ self.env['ir.actions.actions'].clear_caches()
+ return user
+
+ @api.multi
+ def write(self, values):
+ res = super(GroupsView, self).write(values)
+ self._update_user_groups_view()
+ # actions.get_bindings() depends on action records
+ self.env['ir.actions.actions'].clear_caches()
+ return res
+
+ @api.multi
+ def unlink(self):
+ res = super(GroupsView, self).unlink()
+ self._update_user_groups_view()
+ # actions.get_bindings() depends on action records
+ self.env['ir.actions.actions'].clear_caches()
+ return res
+
+ @api.model
+ def _update_user_groups_view(self):
+ """ Modify the view with xmlid ``base.user_groups_view``, which inherits
+ the user form view, and introduces the reified group fields.
+ """
+ if self._context.get('install_mode'):
+ # use installation/admin language for translatable names in the view
+ user_context = self.env['res.users'].context_get()
+ self = self.with_context(**user_context)
+
+ # We have to try-catch this, because at first init the view does not
+ # exist but we are already creating some basic groups.
+ view = self.env.ref('base.user_groups_view', raise_if_not_found=False)
+ if view and view.exists() and view._name == 'ir.ui.view':
+ group_no_one = view.env.ref('base.group_no_one')
+ xml1, xml2 = [], []
+ xml1.append(E.separator(string=_('Application Accesses'), colspan="2"))
+ for app, kind, gs in self.get_groups_by_application():
+ # hide groups in categories 'Hidden' and 'Extra' (except for group_no_one)
+ attrs = {}
+ if app.xml_id in ('base.module_category_hidden', 'base.module_category_extra', 'base.module_category_usability'):
+ attrs['groups'] = 'base.group_no_one'
+
+ if kind == 'selection':
+ # application name with a selection field
+ field_name = name_selection_groups(gs.ids)
+ xml1.append(E.field(name=field_name, **attrs))
+ xml1.append(E.newline())
+ else:
+ # application separator with boolean fields
+ app_name = app.name or _('Other')
+ xml2.append(E.separator(string=app_name, colspan="4", **attrs))
+ for g in gs:
+ field_name = name_boolean_group(g.id)
+ if g == group_no_one:
+ # make the group_no_one invisible in the form view
+ xml2.append(E.field(name=field_name, invisible="1", **attrs))
+ else:
+ xml2.append(E.field(name=field_name, **attrs))
+
+ xml2.append({'class': "o_label_nowrap"})
+ xml = E.field(E.group(*(xml1), col="2"), E.group(*(xml2), col="4"), name="groups_id", position="replace")
+ xml.addprevious(etree.Comment("GENERATED AUTOMATICALLY BY GROUPS"))
+ xml_content = etree.tostring(xml, pretty_print=True, encoding="unicode")
+ view.with_context(lang=None).write({'arch': xml_content, 'arch_fs': False})
+
+ def get_application_groups(self, domain):
+ """ Return the non-share groups that satisfy ``domain``. """
+ return self.search(domain + [('share', '=', False)])
+
+ @api.model
+ def get_groups_by_application(self):
+ """ Return all groups classified by application (module category), as a list::
+
+ [(app, kind, groups), ...],
+
+ where ``app`` and ``groups`` are recordsets, and ``kind`` is either
+ ``'boolean'`` or ``'selection'``. Applications are given in sequence
+ order. If ``kind`` is ``'selection'``, ``groups`` are given in
+ reverse implication order.
+ """
+ def linearize(app, gs):
+ # determine sequence order: a group appears after its implied groups
+ order = {g: len(g.trans_implied_ids & gs) for g in gs}
+ # check whether order is total, i.e., sequence orders are distinct
+ if len(set(order.values())) == len(gs):
+ return (app, 'selection', gs.sorted(key=order.get))
+ else:
+ return (app, 'boolean', gs)
+
+ # classify all groups by application
+ by_app, others = defaultdict(self.browse), self.browse()
+ for g in self.get_application_groups([]):
+ if g.category_id:
+ by_app[g.category_id] += g
+ else:
+ others += g
+ # build the result
+ res = []
+ for app, gs in sorted(by_app.items(), key=lambda it: it[0].sequence or 0):
+ res.append(linearize(app, gs))
+ if others:
+ res.append((self.env['ir.module.category'], 'boolean', others))
+ return res
+
+
+class UsersView(models.Model):
+ _inherit = 'res.users'
+
+ @api.model
+ def create(self, values):
+ values = self._remove_reified_groups(values)
+ user = super(UsersView, self).create(values)
+ group_multi_company = self.env.ref('base.group_multi_company', False)
+ if group_multi_company and 'company_ids' in values:
+ if len(user.company_ids) <= 1 and user.id in group_multi_company.users.ids:
+ group_multi_company.write({'users': [(3, user.id)]})
+ elif len(user.company_ids) > 1 and user.id not in group_multi_company.users.ids:
+ group_multi_company.write({'users': [(4, user.id)]})
+ return user
+
+ @api.multi
+ def write(self, values):
+ values = self._remove_reified_groups(values)
+ res = super(UsersView, self).write(values)
+ group_multi_company = self.env.ref('base.group_multi_company', False)
+ if group_multi_company and 'company_ids' in values:
+ for user in self:
+ if len(user.company_ids) <= 1 and user.id in group_multi_company.users.ids:
+ group_multi_company.write({'users': [(3, user.id)]})
+ elif len(user.company_ids) > 1 and user.id not in group_multi_company.users.ids:
+ group_multi_company.write({'users': [(4, user.id)]})
+ return res
+
+ def _remove_reified_groups(self, values):
+ """ return `values` without reified group fields """
+ add, rem = [], []
+ values1 = {}
+
+ for key, val in values.items():
+ if is_boolean_group(key):
+ (add if val else rem).append(get_boolean_group(key))
+ elif is_selection_groups(key):
+ rem += get_selection_groups(key)
+ if val:
+ add.append(val)
+ else:
+ values1[key] = val
+
+ if 'groups_id' not in values and (add or rem):
+ # remove group ids in `rem` and add group ids in `add`
+ values1['groups_id'] = list(itertools.chain(
+ pycompat.izip(repeat(3), rem),
+ pycompat.izip(repeat(4), add)
+ ))
+
+ return values1
+
+ @api.model
+ def default_get(self, fields):
+ group_fields, fields = partition(is_reified_group, fields)
+ fields1 = (fields + ['groups_id']) if group_fields else fields
+ values = super(UsersView, self).default_get(fields1)
+ self._add_reified_groups(group_fields, values)
+ return values
+
+ @api.multi
+ def read(self, fields=None, load='_classic_read'):
+ # determine whether reified groups fields are required, and which ones
+ fields1 = fields or list(self.fields_get())
+ group_fields, other_fields = partition(is_reified_group, fields1)
+
+ # read regular fields (other_fields); add 'groups_id' if necessary
+ drop_groups_id = False
+ if group_fields and fields:
+ if 'groups_id' not in other_fields:
+ other_fields.append('groups_id')
+ drop_groups_id = True
+ else:
+ other_fields = fields
+
+ res = super(UsersView, self).read(other_fields, load=load)
+
+ # post-process result to add reified group fields
+ if group_fields:
+ for values in res:
+ self._add_reified_groups(group_fields, values)
+ if drop_groups_id:
+ values.pop('groups_id', None)
+ return res
+
+ def _add_reified_groups(self, fields, values):
+ """ add the given reified group fields into `values` """
+ gids = set(parse_m2m(values.get('groups_id') or []))
+ for f in fields:
+ if is_boolean_group(f):
+ values[f] = get_boolean_group(f) in gids
+ elif is_selection_groups(f):
+ selected = [gid for gid in get_selection_groups(f) if gid in gids]
+ values[f] = selected and selected[-1] or False
+
+ @api.model
+ def fields_get(self, allfields=None, attributes=None):
+ res = super(UsersView, self).fields_get(allfields, attributes=attributes)
+ # add reified groups fields
+ for app, kind, gs in self.env['res.groups'].sudo().get_groups_by_application():
+ if kind == 'selection':
+ # selection group field
+ tips = ['%s: %s' % (g.name, g.comment) for g in gs if g.comment]
+ res[name_selection_groups(gs.ids)] = {
+ 'type': 'selection',
+ 'string': app.name or _('Other'),
+ 'selection': [(False, '')] + [(g.id, g.name) for g in gs],
+ 'help': '\n'.join(tips),
+ 'exportable': False,
+ 'selectable': False,
+ }
+ else:
+ # boolean group fields
+ for g in gs:
+ res[name_boolean_group(g.id)] = {
+ 'type': 'boolean',
+ 'string': g.name,
+ 'help': g.comment,
+ 'exportable': False,
+ 'selectable': False,
+ }
+ return res
+
+#----------------------------------------------------------
+# change password wizard
+#----------------------------------------------------------
+
+class ChangePasswordWizard(models.TransientModel):
+ """ A wizard to manage the change of users' passwords. """
+ _name = "change.password.wizard"
+ _description = "Change Password Wizard"
+
+ def _default_user_ids(self):
+ user_ids = self._context.get('active_model') == 'res.users' and self._context.get('active_ids') or []
+ return [
+ (0, 0, {'user_id': user.id, 'user_login': user.login})
+ for user in self.env['res.users'].browse(user_ids)
+ ]
+
+ user_ids = fields.One2many('change.password.user', 'wizard_id', string='Users', default=_default_user_ids)
+
+ @api.multi
+ def change_password_button(self):
+ self.ensure_one()
+ self.user_ids.change_password_button()
+ if self.env.user in self.mapped('user_ids.user_id'):
+ return {'type': 'ir.actions.client', 'tag': 'reload'}
+ return {'type': 'ir.actions.act_window_close'}
+
+
+class ChangePasswordUser(models.TransientModel):
+ """ A model to configure users in the change password wizard. """
+ _name = 'change.password.user'
+ _description = 'Change Password Wizard User'
+
+ wizard_id = fields.Many2one('change.password.wizard', string='Wizard', required=True)
+ user_id = fields.Many2one('res.users', string='User', required=True, ondelete='cascade')
+ user_login = fields.Char(string='User Login', readonly=True)
+ new_passwd = fields.Char(string='New Password', default='')
+
+ @api.multi
+ def change_password_button(self):
+ for line in self:
+ if not line.new_passwd:
+ raise UserError(_("Before clicking on 'Change Password', you have to write a new password."))
+ line.user_id.write({'password': line.new_passwd})
+ # don't keep temporary passwords in the database longer than necessary
+ self.write({'new_passwd': False})