Files
tradon/modules/party/contact_mechanism.py
2026-03-14 09:42:12 +00:00

373 lines
14 KiB
Python

# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
from itertools import chain
try:
import phonenumbers
from phonenumbers import NumberParseException, PhoneNumberFormat
except ImportError:
phonenumbers = None
from trytond.i18n import gettext
from trytond.model import (
DeactivableMixin, Index, ModelSQL, ModelView, MultiValueMixin, ValueMixin,
fields, sequence_ordered)
from trytond.model.exceptions import AccessError
from trytond.pyson import Eval
from trytond.tools.email_ import (
EmailNotValidError, normalize_email, validate_email)
from trytond.transaction import Transaction
from .exceptions import InvalidEMail, InvalidPhoneNumber
_TYPES = [
('phone', 'Phone'),
('mobile', 'Mobile'),
('fax', 'Fax'),
('email', 'Email'),
('website', 'Website'),
('skype', 'Skype'),
('sip', 'SIP'),
('irc', 'IRC'),
('jabber', 'Jabber'),
('other', 'Other'),
]
_PHONE_TYPES = {
'phone',
'mobile',
'fax',
}
class _ContactMechanismMixin:
__slots__ = ()
def contact_mechanism_get(self, types=None, usage=None):
"""
Try to find a contact mechanism for the given types and usage, if no
usage matches the first mechanism of the given types is returned.
"""
default_mechanism = None
if types:
if isinstance(types, str):
types = {types}
mechanisms = [m for m in self.contact_mechanisms
if m.type in types]
else:
mechanisms = self.contact_mechanisms
if mechanisms:
default_mechanism = mechanisms[0]
if usage:
for mechanism in mechanisms:
if getattr(mechanism, usage):
return mechanism
return default_mechanism
class ContactMechanism(
DeactivableMixin, sequence_ordered(), ModelSQL, ModelView,
MultiValueMixin):
__name__ = 'party.contact_mechanism'
_rec_name = 'value'
type = fields.Selection(_TYPES, "Type", required=True, sort=False)
type_string = type.translated('type')
value = fields.Char(
"Value",
# Add all function fields to ensure to always fill them via on_change
depends={
'email', 'website', 'skype', 'sip', 'other_value',
'value_compact'})
value_compact = fields.Char('Value Compact', readonly=True)
name = fields.Char("Name")
comment = fields.Text("Comment")
party = fields.Many2One(
'party.party', "Party", required=True, ondelete='CASCADE')
address = fields.Many2One(
'party.address', "Address", ondelete='CASCADE',
domain=[
('party', '=', Eval('party', -1)),
])
language = fields.MultiValue(
fields.Many2One('ir.lang', "Language",
help="Used to translate communication made "
"using the contact mechanism.\n"
"Leave empty for the party language."))
languages = fields.One2Many(
'party.contact_mechanism.language', 'contact_mechanism', "Languages")
email = fields.Function(fields.Char('Email', states={
'invisible': Eval('type') != 'email',
'required': Eval('type') == 'email',
}, depends={'value'}),
'get_value', setter='set_value')
website = fields.Function(fields.Char('Website', states={
'invisible': Eval('type') != 'website',
'required': Eval('type') == 'website',
}, depends={'value'}),
'get_value', setter='set_value')
skype = fields.Function(fields.Char('Skype', states={
'invisible': Eval('type') != 'skype',
'required': Eval('type') == 'skype',
}, depends={'value'}),
'get_value', setter='set_value')
sip = fields.Function(fields.Char('SIP', states={
'invisible': Eval('type') != 'sip',
'required': Eval('type') == 'sip',
}, depends={'value'}),
'get_value', setter='set_value')
other_value = fields.Function(fields.Char('Value', states={
'invisible': Eval('type').in_(['email', 'website', 'skype', 'sip']),
'required': ~Eval('type').in_(['email', 'website']),
}, depends={'value'}),
'get_value', setter='set_value')
url = fields.Function(fields.Char('URL', states={
'invisible': ~Eval('url'),
}),
'on_change_with_url')
@classmethod
def __setup__(cls):
cls.value.search_unaccented = False
cls.value_compact.search_unaccented = False
super().__setup__()
cls.__access__.add('party')
t = cls.__table__()
cls._sql_indexes.add(
Index(t, (Index.Unaccent(t.value_compact), Index.Similarity())))
cls._order.insert(0, ('party.distance', 'ASC NULLS LAST'))
cls._order.insert(1, ('party', 'ASC'))
@staticmethod
def default_type():
return 'phone'
@classmethod
def default_party(cls):
return Transaction().context.get('related_party')
@fields.depends('address', '_parent_address.party')
def on_change_address(self):
if self.address:
self.party = self.address.party
@classmethod
def get_value(cls, mechanisms, names):
return dict((name, dict((m.id, m.value) for m in mechanisms))
for name in names)
@fields.depends('type', 'value')
def on_change_with_url(self, name=None, value=None):
if value is None:
value = self.value
if self.type == 'email':
return 'mailto:%s' % value
elif self.type == 'website':
return value
elif self.type == 'skype':
return 'callto:%s' % value
elif self.type == 'sip':
return 'sip:%s' % value
elif self.type in {'phone', 'mobile'}:
return 'tel:%s' % value
elif self.type == 'fax':
return 'fax:%s' % value
return None
@fields.depends('party', '_parent_party.addresses')
def _phone_country_codes(self):
if self.party:
for address in self.party.addresses:
if address.country:
yield address.country.code
@fields.depends(methods=['_phone_country_codes'])
def _parse_phonenumber(self, value):
for country_code in chain(self._phone_country_codes(), [None]):
try:
# Country code is ignored if value has an international prefix
phonenumber = phonenumbers.parse(value, country_code)
except NumberParseException:
pass
else:
if phonenumbers.is_valid_number(phonenumber):
return phonenumber
return None
@fields.depends(methods=['_parse_phonenumber'])
def format_value(self, value=None, type_=None):
if phonenumbers and type_ in _PHONE_TYPES:
phonenumber = self._parse_phonenumber(value)
if phonenumber:
value = phonenumbers.format_number(
phonenumber, PhoneNumberFormat.INTERNATIONAL)
if type_ == 'email' and value:
value = normalize_email(value)
return value
@fields.depends(methods=['_parse_phonenumber'])
def format_value_compact(self, value=None, type_=None):
if phonenumbers and type_ in _PHONE_TYPES:
phonenumber = self._parse_phonenumber(value)
if phonenumber:
value = phonenumbers.format_number(
phonenumber, PhoneNumberFormat.E164)
return value
@classmethod
def set_value(cls, mechanisms, name, value):
# Setting value is done by on_changes
pass
@fields.depends(
methods=['on_change_with_url', 'format_value', 'format_value_compact'])
def _change_value(self, value, type_):
self.value = self.format_value(value=value, type_=type_)
self.value_compact = self.format_value_compact(
value=value, type_=type_)
self.website = value
self.email = value
self.skype = value
self.sip = value
self.other_value = value
self.url = self.on_change_with_url(value=value)
@fields.depends('value', 'type', methods=['_change_value'])
def on_change_value(self):
return self._change_value(self.value, self.type)
@fields.depends('website', 'type', methods=['_change_value'])
def on_change_website(self):
return self._change_value(self.website, self.type)
@fields.depends('email', 'type', methods=['_change_value'])
def on_change_email(self):
return self._change_value(self.email, self.type)
@fields.depends('skype', 'type', methods=['_change_value'])
def on_change_skype(self):
return self._change_value(self.skype, self.type)
@fields.depends('sip', 'type', methods=['_change_value'])
def on_change_sip(self):
return self._change_value(self.sip, self.type)
@fields.depends('other_value', 'type', methods=['_change_value'])
def on_change_other_value(self):
return self._change_value(self.other_value, self.type)
def get_rec_name(self, name):
name = self.name or self.party.rec_name
return '%s <%s>' % (name, self.value_compact or self.value)
@classmethod
def search_rec_name(cls, name, clause):
if clause[1].startswith('!') or clause[1].startswith('not '):
bool_op = 'AND'
else:
bool_op = 'OR'
return [bool_op,
('value',) + tuple(clause[1:]),
('value_compact',) + tuple(clause[1:]),
]
def compute_fields(self, field_names=None):
values = super().compute_fields(field_names=field_names)
if field_names is None or {'value', 'type'} & field_names:
if getattr(self, 'value', None) and getattr(self, 'type', None):
value = self.format_value(value=self.value, type_=self.type)
if self.value != value:
values['value'] = value
value_compact = self.format_value_compact(
value=self.value, type_=self.type)
if getattr(self, 'value_compact', None) != value_compact:
values['value_compact'] = value_compact
return values
@classmethod
def check_modification(cls, mode, mechanisms, values=None, external=False):
super().check_modification(
mode, mechanisms, values=values, external=external)
if mode == 'write' and 'party' in values:
for mechanism in mechanisms:
if mechanism.party.id != values['party']:
raise AccessError(gettext(
'party.msg_contact_mechanism_change_party',
contact=mechanism.rec_name))
@classmethod
def validate_fields(cls, mechanisms, field_names):
super().validate_fields(mechanisms, field_names)
cls.check_valid_phonenumber(mechanisms, field_names)
cls.check_valid_email(mechanisms, field_names)
@classmethod
def check_valid_phonenumber(cls, mechanisms, field_names=None):
if field_names and not (field_names & {'type', 'value'}):
return
if not phonenumbers:
return
for mechanism in mechanisms:
if mechanism.type not in _PHONE_TYPES:
continue
if not mechanism._parse_phonenumber(mechanism.value):
raise InvalidPhoneNumber(
gettext('party.msg_invalid_phone_number',
phone=mechanism.value, party=mechanism.party.rec_name))
@classmethod
def check_valid_email(cls, mechanisms, field_names=None):
if field_names and not (field_names & {'type', 'value'}):
return
for mechanism in mechanisms:
if mechanism.type == 'email' and mechanism.value:
try:
validate_email(mechanism.value)
except EmailNotValidError as e:
raise InvalidEMail(gettext(
'party.msg_email_invalid',
email=mechanism.value,
party=mechanism.party.rec_name),
str(e)) from e
@classmethod
def usages(cls, _fields=None):
"Returns the selection list of usage"
usages = [(None, "")]
if _fields:
for name, desc in cls.fields_get(_fields).items():
usages.append((name, desc['string']))
return usages
@fields.depends(methods=['_notify_duplicate'])
def on_change_notify(self):
notifications = super().on_change_notify()
notifications.extend(self._notify_duplicate())
return notifications
@fields.depends('value', 'type', methods=['format_value_compact'])
def _notify_duplicate(self):
cls = self.__class__
if self.value and self.type:
value_compact = self.format_value_compact(self.value, self.type)
others = cls.search([
('id', '!=', self.id),
('type', '=', self.type),
('value_compact', '=', value_compact),
], limit=1)
if others:
other, = others
yield ('warning', gettext(
'party.msg_party_contact_mechanism_duplicate',
party=other.party.rec_name,
type=other.type_string,
value=other.value))
class ContactMechanismLanguage(ModelSQL, ValueMixin):
__name__ = 'party.contact_mechanism.language'
contact_mechanism = fields.Many2One(
'party.contact_mechanism', "Contact Mechanism",
ondelete='CASCADE')
language = fields.Many2One('ir.lang', "Language")