# 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")