diff --git a/partner_second_lastname/README.rst b/partner_second_lastname/README.rst new file mode 100644 index 000000000..a7e59b726 --- /dev/null +++ b/partner_second_lastname/README.rst @@ -0,0 +1,72 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :alt: License: AGPL-3 + +Partner second lastname +======================= + +This module was written to extend the functionality of ``partner_firstname`` to +support having a second lastname for contact partners. + +In some countries, it's important to have a second last name for contacts. + +Contact partners will need to fulfill at least one of the name fields +(*First name*, *First last name* or *Second last name*). + +Usage +===== + +To use this module, you need to: + +* Edit any partner's form. +* Make sure the partner is not a company. +* Enter firstname and lastnames. + +If you directly enter the full name instead of entering the other fields +separately (maybe from other form), this module will try to guess the best +match for your input and split it between firstname, lastname and second +lastname. + +If the name you enter is in the form *Firstname Lastname1 Lastname2*, it will +be split as such. If you use a comma, it will understand it as *Lastname1 +Lastname2, Firstname*. + +If you can, always enter it manually please. Automatic guessing could fail for +you easily in some corner cases. + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/134/8.0 + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smashing it by providing a detailed and welcomed feedback `here +`_. + + +Credits +======= + +Contributors +------------ + +* `Grupo ESOC `_: + * `Jairo Llopis `_. + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit http://odoo-community.org. diff --git a/partner_second_lastname/__init__.py b/partner_second_lastname/__init__.py new file mode 100644 index 000000000..27c5e587c --- /dev/null +++ b/partner_second_lastname/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +# © 2015 Grupo ESOC Ingeniería de Servicios, S.L.U. + +from . import models diff --git a/partner_second_lastname/__openerp__.py b/partner_second_lastname/__openerp__.py new file mode 100644 index 000000000..128ce3e33 --- /dev/null +++ b/partner_second_lastname/__openerp__.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +# © 2015 Grupo ESOC Ingeniería de Servicios, S.L.U. + +{ + "name": "Partner second last name", + "version": "8.0.4.0.0", + "author": "Grupo ESOC, Odoo Community Association (OCA)", + "license": "AGPL-3", + "maintainer": "Odoo Community Association (OCA)", + "category": "Extra Tools", + "website": "http://www.grupoesoc.es", + "depends": [ + "partner_firstname" + ], + "data": [ + "views/res_partner.xml", + "views/res_user.xml", + ], + "installable": True, +} diff --git a/partner_second_lastname/i18n/es.po b/partner_second_lastname/i18n/es.po new file mode 100644 index 000000000..11ee505fa --- /dev/null +++ b/partner_second_lastname/i18n/es.po @@ -0,0 +1,68 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * partner_lastname2 +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 8.0-20150327\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-08-13 11:16+0100\n" +"PO-Revision-Date: 2015-08-13 11:16+0100\n" +"Last-Translator: Jairo Llopis \n" +"Language-Team: \n" +"Language: es_ES\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 1.8.1\n" + +#. module: partner_second_lastname +#: model:ir.model,name:partner_second_lastname.model_res_partner +msgid "Partner" +msgstr "Empresa" + +#. module: partner_second_lastname +#: field:res.partner,lastname2:0 +msgid "Second last name" +msgstr "Segundo apellido" + +#. module: partner_second_lastname +#: view:res.partner:partner_second_lastname.partner_form +#: view:res.partner:partner_second_lastname.partner_simple_form +msgid "" +"{\n" +" 'required': [('firstname', '=', False),\n" +" ('lastname2', '=', False),\n" +" ('is_company', '=', False)]\n" +" }" +msgstr "" + +#. module: partner_second_lastname +#: view:res.users:partner_second_lastname.users_form +msgid "" +"{\n" +" 'required': [('firstname', '=', False),\n" +" ('lastname2', '=', False)]\n" +" }" +msgstr "" + +#. module: partner_second_lastname +#: view:res.partner:partner_second_lastname.partner_form +#: view:res.partner:partner_second_lastname.partner_simple_form +msgid "" +"{\n" +" 'required': [('lastname', '=', False),\n" +" ('lastname2', '=', False),\n" +" ('is_company', '=', False)]\n" +" }" +msgstr "" + +#. module: partner_second_lastname +#: view:res.users:partner_second_lastname.users_form +msgid "" +"{\n" +" 'required': [('lastname', '=', False),\n" +" ('lastname2', '=', False)]\n" +" }" +msgstr "" diff --git a/partner_second_lastname/i18n/partner_second_lastname.pot b/partner_second_lastname/i18n/partner_second_lastname.pot new file mode 100644 index 000000000..fdf7d1005 --- /dev/null +++ b/partner_second_lastname/i18n/partner_second_lastname.pot @@ -0,0 +1,63 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * partner_second_lastname +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 8.0-20150811\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-08-13 09:15+0000\n" +"PO-Revision-Date: 2015-08-13 09:15+0000\n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: partner_second_lastname +#: model:ir.model,name:partner_second_lastname.model_res_partner +msgid "Partner" +msgstr "" + +#. module: partner_second_lastname +#: field:res.partner,lastname2:0 +msgid "Second last name" +msgstr "" + +#. module: partner_second_lastname +#: view:res.partner:partner_second_lastname.partner_form +#: view:res.partner:partner_second_lastname.partner_simple_form +msgid "{\n" +" 'required': [('firstname', '=', False),\n" +" ('lastname2', '=', False),\n" +" ('is_company', '=', False)]\n" +" }" +msgstr "" + +#. module: partner_second_lastname +#: view:res.users:partner_second_lastname.users_form +msgid "{\n" +" 'required': [('firstname', '=', False),\n" +" ('lastname2', '=', False)]\n" +" }" +msgstr "" + +#. module: partner_second_lastname +#: view:res.partner:partner_second_lastname.partner_form +#: view:res.partner:partner_second_lastname.partner_simple_form +msgid "{\n" +" 'required': [('lastname', '=', False),\n" +" ('lastname2', '=', False),\n" +" ('is_company', '=', False)]\n" +" }" +msgstr "" + +#. module: partner_second_lastname +#: view:res.users:partner_second_lastname.users_form +msgid "{\n" +" 'required': [('lastname', '=', False),\n" +" ('lastname2', '=', False)]\n" +" }" +msgstr "" + diff --git a/partner_second_lastname/models.py b/partner_second_lastname/models.py new file mode 100644 index 000000000..d2b48741c --- /dev/null +++ b/partner_second_lastname/models.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +# © 2015 Grupo ESOC Ingeniería de Servicios, S.L.U. + +from openerp import api, fields, models +from openerp.addons.partner_firstname import exceptions + + +class ResPartner(models.Model): + """Adds a second last name.""" + + _inherit = "res.partner" + + lastname2 = fields.Char("Second last name") + + @api.model + def _get_computed_name(self, lastname, firstname, lastname2=None): + """Compute the name combined with the second lastname too. + + We have 2 lastnames, so lastnames and firstname will be separated by a + comma. + """ + names = list() + + if lastname: + names.append(lastname) + if lastname2: + names.append(lastname2) + if names and firstname: + names[-1] = names[-1] + "," + if firstname: + names.append(firstname) + + return u" ".join(names) + + @api.one + @api.depends("firstname", "lastname", "lastname2") + def _compute_name(self): + """Write :attr:`~.name` according to splitted data.""" + self.name = self._get_computed_name(self.lastname, + self.firstname, + self.lastname2) + + @api.one + def _inverse_name(self): + """Try to revert the effect of :meth:`._compute_name`.""" + parts = self._get_inverse_name(self.name, self.is_company) + + # Avoid to hit :meth:`~._check_name` with all 3 fields being ``False`` + before, after = dict(), dict() + for key, value in parts.iteritems(): + (before if value else after)[key] = value + self.update(before) + self.update(after) + + @api.model + def _get_inverse_name(self, name, is_company=False): + """Compute the inverted name. + + - If the partner is a company, save it in the lastname. + - Otherwise, make a guess. + """ + # Company name goes to the lastname + if is_company or not name: + parts = [False, name or False, False] + + # The comma separates the firstname + elif "," in name: + lastnames, firstname = name.split(",", 1) + parts = [firstname.strip()] + lastnames.split(" ", 1) + + # Without comma, the user wrote the firstname first + else: + parts = name.split(" ", 2) + + while len(parts) < 3: + parts.append(False) + + return {"firstname": parts[0], + "lastname": parts[1], + "lastname2": parts[2]} + + @api.one + @api.constrains("firstname", "lastname", "lastname2") + def _check_name(self): + """Ensure at least one name is set.""" + try: + super(ResPartner, self)._check_name() + except exceptions.EmptyNamesError as error: + if not self.lastname2: + raise error + + @api.one + @api.onchange("firstname", "lastname", "lastname2") + def _onchange_subnames(self): + """Trigger onchange with :attr:`~.lastname2` too.""" + super(ResPartner, self)._onchange_subnames() diff --git a/partner_second_lastname/tests/__init__.py b/partner_second_lastname/tests/__init__.py new file mode 100644 index 000000000..36f666cf3 --- /dev/null +++ b/partner_second_lastname/tests/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +# © 2015 Grupo ESOC Ingeniería de Servicios, S.L.U. + +from . import test_name, test_onchange +from openerp.addons.partner_firstname.tests import test_empty diff --git a/partner_second_lastname/tests/test_name.py b/partner_second_lastname/tests/test_name.py new file mode 100644 index 000000000..ba97e467d --- /dev/null +++ b/partner_second_lastname/tests/test_name.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +# © 2015 Grupo ESOC Ingeniería de Servicios, S.L.U. + +from openerp.tests.common import TransactionCase +from openerp.addons.partner_firstname.tests.base import MailInstalled + + +class CompanyCase(TransactionCase): + """Test ``res.partner`` when it is a company.""" + def tearDown(self): + try: + new = self.env["res.partner"].create({ + "is_company": True, + "name": self.name, + }) + + # Name should be cleaned of unneeded whitespace + clean_name = u" ".join(self.name.split(None)) + + # Check it's saved OK + self.assertEqual( + new.name, + clean_name, + "Saved company name is wrong.") + + # Check it's saved in the lastname + self.assertEqual( + new.lastname, + clean_name, + "Company name should be saved in the lastname field.") + + # Check that other fields are empty + self.assertEqual( + new.firstname, + False, + "Company first name must always be empty.") + self.assertEqual( + new.lastname2, + False, + "Company last name 2 must always be empty.") + + finally: + super(CompanyCase, self).tearDown() + + def test_long_name(self): + """Create a company with a long name.""" + self.name = u"Söme very lóng nâme" + + def test_short_name(self): + """Create a company with a short name.""" + self.name = u"Shoŕt" + + def test_whitespace_before(self): + """Create a company with name prefixed with whitespace.""" + self.name = u" Wĥitespace befòre" + + def test_whitespace_after(self): + """Create a company with name suffixed with whitespace.""" + self.name = u"Whitespâce aftér " + + def test_whitespace_inside(self): + """Create a company with whitespace inside the name.""" + self.name = u"Whitespacé ïnside" + + def test_whitespace_everywhere(self): + """Create a company with whitespace everywhere in the name.""" + self.name = u" A lot öf whitespace " + + +class PersonCase(TransactionCase): + """Test ``res.partner`` when it is a person.""" + model = "res.partner" + context = dict() + + def setUp(self): + super(PersonCase, self).setUp() + + self.firstname = u"Fírstname" + self.lastname = u"Làstname1" + self.lastname2 = u"Lâstname2" + self.template = u"%(last1)s %(last2)s, %(first)s" + + def tearDown(self): + try: + new = (self.env[self.model].with_context(self.context) + .create(self.params)) + + # Check that each individual field matches + self.assertEqual( + self.firstname, + new.firstname, + "First name saved badly.") + self.assertEqual( + self.lastname, + new.lastname, + "Last name 1 saved badly.") + self.assertEqual( + self.lastname2, + new.lastname2, + "Last name 2 saved badly.") + + # Check that name gets saved fine + self.assertEqual( + self.template % ({"last1": self.lastname, + "last2": self.lastname2, + "first": self.firstname}), + new.name, + "Name saved badly.") + + finally: + super(PersonCase, self).tearDown() + + def test_firstname_first(self): + """Create a person setting his first name first.""" + self.params = { + "is_company": False, + "name": "%s %s %s" % (self.firstname, + self.lastname, + self.lastname2), + } + + def test_firstname_last(self): + """Create a persong setting his first name last.""" + self.params = { + "is_company": False, + "name": "%s %s, %s" % (self.lastname, + self.lastname2, + self.firstname), + } + + def test_firstname_only(self): + """Create a persong setting his first name only.""" + self.lastname = self.lastname2 = False + self.template = "%(first)s" + self.params = { + "is_company": False, + "name": self.firstname, + } + + def test_firstname_lastname_only(self): + """Create a persong setting his first name and last name 1 only.""" + self.lastname2 = False + self.template = "%(last1)s, %(first)s" + self.params = { + "is_company": False, + "name": "%s %s" % (self.firstname, self.lastname), + } + + def test_lastname_firstname_only(self): + """Create a persong setting his last name 1 and first name only.""" + self.lastname2 = False + self.template = "%(last1)s, %(first)s" + self.params = { + "is_company": False, + "name": "%s, %s" % (self.lastname, self.firstname), + } + + def test_separately(self): + """Create a person setting separately all fields.""" + self.params = { + "is_company": False, + "firstname": self.firstname, + "lastname": self.lastname, + "lastname2": self.lastname2, + } + + +class UserCase(PersonCase, MailInstalled): + """Test ``res.users``.""" + model = "res.users" + context = {"default_login": "user@example.com"} + + def tearDown(self): + # Skip if ``mail`` is installed + if not self.mail_installed(): + super(UserCase, self).tearDown() diff --git a/partner_second_lastname/tests/test_onchange.py b/partner_second_lastname/tests/test_onchange.py new file mode 100644 index 000000000..fdf71a424 --- /dev/null +++ b/partner_second_lastname/tests/test_onchange.py @@ -0,0 +1,213 @@ +# -*- coding: utf-8 -*- +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +# © 2015 Grupo ESOC Ingeniería de Servicios, S.L.U. +"""These tests try to mimic the behavior of the UI form. + +The form operates in onchange mode, with its limitations. +""" + +from openerp.tests.common import TransactionCase + + +class OnChangeCase(TransactionCase): + is_company = False + + def new_partner(self): + """Create an empty partner. Ensure it is (or not) a company.""" + new = self.env["res.partner"].new() + new.is_company = self.is_company + return new + + +class PartnerCompanyCase(OnChangeCase): + is_company = True + + def tearDown(self): + """Companies never have ``firstname`` nor ``lastname2``.""" + self.assertEqual(self.partner.firstname, False) + self.assertEqual(self.partner.lastname2, False) + + def set_name(self, value): + self.partner.name = value + + # It triggers onchange + self.partner._onchange_name() + + # Ensure it's properly set + self.assertEqual(self.partner.name, value) + + def test_create_from_form(self): + """A user creates a company from the form.""" + name = u"Sôme company" + with self.env.do_in_onchange(): + # User presses ``new`` + self.partner = self.new_partner() + + # User changes fields + self.set_name(name) + + self.assertEqual(self.partner.lastname, name) + + def test_empty_name_and_subnames(self): + """If the user empties ``name``, subnames must be ``False``. + + Otherwise, the ``required`` attr will not work as expected. + """ + with self.env.do_in_onchange(): + # User presses ``new`` + self.partner = self.new_partner() + + # User changes fields + self.set_name(u"Fóo") + self.set_name(u"") + + self.assertEqual(self.partner.lastname, False) + + +class PartnerContactCase(OnChangeCase): + def set_field(self, field, value): + # Changes the field + setattr(self.partner, field, value) + + if field in ("firstname", "lastname", "lastname2"): + # Trigger onchanges + self.partner._onchange_subnames() + self.partner._onchange_name() + + # Check it's set OK + self.assertEqual(getattr(self.partner, field), value) + + def test_create_from_form_empty(self): + """A user creates a contact from the form. + + All subfields must be false, or the ``required`` attr will not work as + expected. + """ + with self.env.do_in_onchange(): + # User presses ``new`` + self.partner = self.new_partner() + + # Odoo tries to compute the name + self.partner._compute_name() + + # This is then triggered + self.partner._onchange_name() + + # Subnames must start as False to make the UI work fine + self.assertEqual(self.partner.firstname, False) + self.assertEqual(self.partner.lastname, False) + self.assertEqual(self.partner.lastname2, False) + + # ``name`` cannot be False, or upstream Odoo will fail + self.assertEqual(self.partner.name, u"") + + def test_create_from_form_only_firstname(self): + """A user creates a contact with only the firstname from the form.""" + firstname = u"Fïrst" + with self.env.do_in_onchange(): + # User presses ``new`` + self.partner = self.new_partner() + + # User changes fields + self.set_field("firstname", firstname) + + self.assertEqual(self.partner.lastname, False) + self.assertEqual(self.partner.lastname2, False) + self.assertEqual(self.partner.name, firstname) + + def test_create_from_form_only_lastname(self): + """A user creates a contact with only the lastname from the form.""" + lastname = u"Läst" + with self.env.do_in_onchange(): + # User presses ``new`` + self.partner = self.new_partner() + + # User changes fields + self.set_field("lastname", lastname) + + self.assertEqual(self.partner.firstname, False) + self.assertEqual(self.partner.lastname2, False) + self.assertEqual(self.partner.name, lastname) + + def test_create_from_form_only_lastname2(self): + """A user creates a contact with only the lastname2 from the form.""" + lastname2 = u"Läst2" + with self.env.do_in_onchange(): + # User presses ``new`` + self.partner = self.new_partner() + + # User changes fields + self.set_field("lastname2", lastname2) + + self.assertEqual(self.partner.firstname, False) + self.assertEqual(self.partner.lastname, False) + self.assertEqual(self.partner.name, lastname2) + + def test_create_from_without_firstname(self): + """A user creates a contact without firstname from the form.""" + lastname = u"Läst" + lastname2 = u"Läst2" + with self.env.do_in_onchange(): + # User presses ``new`` + self.partner = self.new_partner() + + # User changes fields + self.set_field("lastname", lastname) + self.set_field("lastname2", lastname2) + + self.assertEqual(self.partner.firstname, False) + self.assertEqual( + self.partner.name, + u"%s %s" % (lastname, lastname2)) + + def test_create_from_without_lastname(self): + """A user creates a contact without lastname from the form.""" + firstname = u"Fïrst" + lastname2 = u"Läst2" + with self.env.do_in_onchange(): + # User presses ``new`` + self.partner = self.new_partner() + + # User changes fields + self.set_field("firstname", firstname) + self.set_field("lastname2", lastname2) + + self.assertEqual(self.partner.lastname, False) + self.assertEqual( + self.partner.name, + u"%s, %s" % (lastname2, firstname)) + + def test_create_from_without_lastname2(self): + """A user creates a contact without lastname2 from the form.""" + firstname = u"Fïrst" + lastname = u"Läst" + with self.env.do_in_onchange(): + # User presses ``new`` + self.partner = self.new_partner() + + # User changes fields + self.set_field("firstname", firstname) + self.set_field("lastname", lastname) + + self.assertEqual(self.partner.lastname2, False) + self.assertEqual( + self.partner.name, + u"%s, %s" % (lastname, firstname)) + + def test_create_from_form_all(self): + """A user creates a contact with all names from the form.""" + firstname = u"Fïrst" + lastname = u"Läst" + lastname2 = u"Läst2" + with self.env.do_in_onchange(): + # User presses ``new`` + self.partner = self.new_partner() + + # User changes fields + self.set_field("firstname", firstname) + self.set_field("lastname", lastname) + self.set_field("lastname2", lastname2) + + self.assertEqual( + self.partner.name, + u"%s %s, %s" % (lastname, lastname2, firstname)) diff --git a/partner_second_lastname/views/res_partner.xml b/partner_second_lastname/views/res_partner.xml new file mode 100644 index 000000000..94a710ce1 --- /dev/null +++ b/partner_second_lastname/views/res_partner.xml @@ -0,0 +1,105 @@ + + + + + + + Add second last name + res.partner + + + + + { + 'required': [('lastname', '=', False), + ('lastname2', '=', False), + ('is_company', '=', False)] + } + + + + { + 'required': [('firstname', '=', False), + ('lastname2', '=', False), + ('is_company', '=', False)] + } + + + + + + + + + + + Add second last name + res.partner + + + + + + { + 'required': [('lastname', '=', False), + ('lastname2', '=', False), + ('is_company', '=', False)] + } + + + + { + 'required': [('firstname', '=', False), + ('lastname2', '=', False), + ('is_company', '=', False)] + } + + + + + + + + + { + 'required': [('lastname', '=', False), + ('lastname2', '=', False), + ('is_company', '=', False)] + } + + + + { + 'required': [('firstname', '=', False), + ('lastname2', '=', False), + ('is_company', '=', False)] + } + + + + + + + + + + + diff --git a/partner_second_lastname/views/res_user.xml b/partner_second_lastname/views/res_user.xml new file mode 100644 index 000000000..76ae48121 --- /dev/null +++ b/partner_second_lastname/views/res_user.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + Add second last name + res.users + + + + + { + 'required': [('lastname', '=', False), + ('lastname2', '=', False)] + } + + + + { + 'required': [('firstname', '=', False), + ('lastname2', '=', False)] + } + + + + + + + + + + +