diff --git a/connect/COPYRIGHT b/connect/COPYRIGHT new file mode 100644 index 00000000..e22ae27d --- /dev/null +++ b/connect/COPYRIGHT @@ -0,0 +1,19 @@ +Copyright (c) 2025 Oduist OÜ + +ADDITIONAL RESTRICTIONS: + +The following components of this Software are protected against modification: + +1. License validation mechanisms +2. Payment processing and billing logic +3. Subscription verification systems +4. Authentication and authorization related to licensing +5. Any code that communicates with Oduist licensing servers + +Modification, removal, or circumvention of these protection mechanisms +constitutes a material breach of this license and may result in: +- Immediate termination of your license +- Legal action for damages +- Criminal prosecution where applicable + +For questions regarding these restrictions, contact: team@oduist.com diff --git a/connect/LICENSE b/connect/LICENSE index 735aac25..000e8698 100644 --- a/connect/LICENSE +++ b/connect/LICENSE @@ -1,71 +1,30 @@ -Business Source License -License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved. - -"Business Source License" is a trademark of MariaDB Corporation Ab. - -Licensor: Oduist OÜ - -Licensed Work: The Licensed Work is (c) 2025 Oduist OÜ - -Additional Use Grant: - -You may make production use of the Licensed Work, provided that your use does not include: - -- Reselling or Redistributing the Licensed Work: You may not offer, distribute, sublicense, sell, - lease, or otherwise transfer the Licensed Work to any third party in any form, whether modified or unmodified. -- Providing Support, Customization, or other Services: You may not offer any paid or unpaid support, - consulting, or customization services related to the Licensed Work to third parties. -- Offering the Licensed Work as a Hosted Service or Embedding It in a Product: You may not offer the - Licensed Work on a hosted basis, integrate it into another service, or embed it in any product - that is provided to third parties. -- Competing with Oduist OÜ: You may not use the Licensed Work in any way that competes with - Oduist OÜ’s offerings, including its paid support and service agreements. - -For purposes of this license: - -- "Product" means software that is offered to end users to manage in their own environments or - offered as a service on a hosted basis. -- "Embedded" means including the source code from the Licensed Work in a competitive offering. - "Embedded" also means packaging the competitive offering in such a way that the Licensed Work must be accessed or downloaded for the competitive offering to operate. - -Hosting or using the Licensed Work(s) for internal purposes within an organization is not considered a competitive offering. Oduist OÜ considers your organization to include all of your affiliates under common control. - -For binding interpretive guidance on using Oduist products under the Business Source License, please visit our WEB site (https://oduist.com/bsl). - -Change Date: 1 Oct 2028 - -Change License: LGPLv3 - -For information about alternative licensing arrangements for the Licensed Work, please contact team@oduist.com - -Terms -The Licensor hereby grants you the right to copy, modify, create derivative works, redistribute, and -make non-production use of the Licensed Work. The Licensor may make an Additional Use Grant, above, -permitting limited production use. - -Effective on the Change Date, or the fourth anniversary of the first publicly available distribution of -a specific version of the Licensed Work under this License, whichever comes first, the Licensor hereby -grants you rights under the terms of the Change License, and the rights granted in the paragraph above -terminate. - -If your use of the Licensed Work does not comply with the requirements currently in effect as described -in this License, you must purchase a commercial license from the Licensor, its affiliated entities, or -authorized resellers, or you must refrain from using the Licensed Work. - -All copies of the original and modified Licensed Work, and derivative works of the Licensed Work, are -subject to this License. This License applies separately for each version of the Licensed Work and the -Change Date may vary for each version of the Licensed Work released by Licensor. - -You must conspicuously display this License on each original or modified copy of the Licensed Work. If -you receive the Licensed Work in original or modified form from a third party, the terms and conditions -set forth in this License apply to your use of that work. - -Any use of the Licensed Work in violation of this License will automatically terminate your rights under -this License for the current and all other versions of the Licensed Work. - -This License does not grant you any right in any trademark or logo of Licensor or its affiliates -(provided that you may use a trademark or logo of Licensor as expressly required by this License). - -TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN "AS IS" BASIS. LICENSOR -HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) -WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. +Oduist Proprietary License v1.0 + +This software and associated files (the "Software") may only be used +(executed, modified, executed after modifications) if you have purchased +a valid license from Oduist OÜ via https://oduist.com or if you have received +a written agreement from Oduist OÜ. + +You may develop Odoo modules that use the Software as a library (typically +by depending on it, importing it and using its resources), but without +copying any source code or material from the Software. You may distribute +those modules under the license of your choice, provided that this license +is compatible with the terms of the Oduist Proprietary License. + +It is forbidden to publish, distribute, sublicense, or sell copies of the +Software or modified copies of the Software. + +Any modifications to the Software's licensing validation, payment processing, +or subscription verification mechanisms are strictly prohibited. See the +COPYRIGHT file for additional restrictions. + +The above copyright notice and this permission notice must be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/connect/__init__.py b/connect/__init__.py index e4f4917a..619a07f8 100644 --- a/connect/__init__.py +++ b/connect/__init__.py @@ -1,3 +1,36 @@ +# -*- coding: utf-8 -*- +""" +ODUIST PROPRIETARY LICENSE +Copyright (c) 2025 Oduist + +This file contains license validation logic. +Modification is prohibited under Oduist Proprietary License. +See LICENSE and COPYRIGHT files for full terms. +""" + from . import controllers from . import models from . import wizard + +import logging +from odoo import fields, api, SUPERUSER_ID + +_logger = logging.getLogger(__name__) +def post_init_hook(*args): + try: + # Handle different Odoo versions + if len(args) == 1: + # Odoo 16+ - single env argument + env = args[0] + else: + # Odoo 15 - cr and registry arguments + cr, registry = args + env = api.Environment(cr, SUPERUSER_ID, {}) + # Find the connect module record + module = env['ir.module.module'].search([('name', '=', 'connect')], limit=1) + if module: + module.write({'create_date': fields.Datetime.now()}) + # Update module pricing. + env['oduist.license'].update_license_status(raise_exc=False) + except Exception as e: + _logger.error('Error in post_init_hook: %s', str(e)) diff --git a/connect/__manifest__.py b/connect/__manifest__.py index 8d759641..9eaab92f 100644 --- a/connect/__manifest__.py +++ b/connect/__manifest__.py @@ -1,80 +1,83 @@ # -*- encoding: utf-8 -*- { - 'name': 'Connect', - 'version': '1.0.13', - 'author': 'Oduist', - 'maintainer': 'Oduist', - 'live_test_url': 'https://connect-demo-18.oduist.com/', - 'price': 0, - 'currency': 'EUR', - 'support': 'support@oduist.com', - 'license': 'Other proprietary', - 'category': 'Phone', - 'summary': 'Twilio and Odoo integration application', - 'description': '', - 'depends': ['mail', 'contacts', 'sms'], - 'external_dependencies': { - 'python': ['twilio', 'openai'], + "name": "Connect", + "version": "2.0.1", + "author": "Oduist", + "maintainer": "Oduist", + "live_test_url": "https://connect-demo.oduist.com/", + "price": 0, # TRIAL + "currency": "EUR", + "support": "support@oduist.com", + "license": "Other proprietary", + "category": "Phone", + "summary": "Twilio and Odoo integration application", + "description": "", + "depends": ["mail", "contacts", "sms"], + "external_dependencies": { + "python": ["twilio", "openai", "PyJWT"], }, - 'data': [ - 'data/res_users.xml', - 'data/data.xml', - 'data/functions.xml', - 'data/ir_cron.xml', - 'data/twiml.xml', - 'data/res_partner.xml', - 'data/whatsapp_templates.xml', + "sequences": True, + "data": [ + "data/res_users.xml", + "data/license.xml", + "data/ir_cron.xml", + "data/twiml.xml", + "data/res_partner.xml", + "data/whatsapp_templates.xml", # Security - 'security/groups.xml', - 'security/admin.xml', - 'security/webhook.xml', - 'security/user.xml', - 'security/user_record_rules.xml', - 'security/admin_record_rules.xml', + "security/groups.xml", + "security/admin.xml", + "security/webhook.xml", + "security/user.xml", + "security/license.xml", + "security/user_record_rules.xml", + "security/admin_record_rules.xml", # Views - 'views/menu.xml', - 'views/settings.xml', - 'views/domain.xml', - 'views/user.xml', - 'views/twiml.xml', - 'views/debug.xml', - 'views/exten.xml', - 'views/call.xml', - 'views/callflow.xml', - 'views/channel.xml', - 'views/outgoing_callerid.xml', - 'views/recording.xml', - 'views/number.xml', - 'views/favorite.xml', - 'views/res_partner.xml', - 'views/message.xml', - 'views/message_configuration.xml', - 'views/message_content_template.xml', - 'views/whatsapp_sender.xml', - 'views/versions.xml', - 'views/documentation.xml', + "views/menu.xml", + "views/settings.xml", + "views/license.xml", + "views/domain.xml", + "views/user.xml", + "views/twiml.xml", + "views/debug.xml", + "views/exten.xml", + "views/call.xml", + "views/callflow.xml", + "views/channel.xml", + "views/outgoing_callerid.xml", + "views/recording.xml", + "views/number.xml", + "views/favorite.xml", + "views/res_partner.xml", + "views/message.xml", + "views/message_configuration.xml", + "views/message_content_template.xml", + "views/whatsapp_sender.xml", + "views/documentation.xml", # Wizard - 'wizard/transfer.xml', - 'wizard/sms_composer_views.xml', - 'wizard/whatsapp_composer_views.xml', - 'wizard/originate_to_wizard_views.xml', + "wizard/transfer.xml", + "wizard/sms_composer_views.xml", + "wizard/whatsapp_composer_views.xml", + "wizard/originate_to_wizard_views.xml", ], - 'demo': [], - 'installable': True, - 'application': True, - 'auto_install': False, - 'images': ['static/description/logo.png'], - 'assets': { - 'web.assets_backend': [ - '/connect/static/src/icomoon/style.css', - '/connect/static/src/components/phone/*/*', - '/connect/static/src/js/main.js', - '/connect/static/src/js/utils.js', - '/connect/static/src/widgets/phone_field/*', - '/connect/static/src/services/actions/*', - '/connect/static/src/services/active_calls/*', - '/connect/static/src/services/mail/*', + "demo": [], + "installable": True, + "application": True, + "auto_install": False, + "images": ["static/description/logo.png"], + "assets": { + "web.assets_backend": [ + "/connect/static/src/icomoon/style.css", + "/connect/static/src/components/phone/*/*", + "/connect/static/src/components/license_banner/*", + "/connect/static/src/js/main.js", + "/connect/static/src/js/utils.js", + "/connect/static/src/widgets/phone_field/*", + "/connect/static/src/services/actions/*", + "/connect/static/src/services/active_calls/*", + "/connect/static/src/services/mail/*", ], }, + "post_init_hook": "post_init_hook", } diff --git a/connect/controllers/main.py b/connect/controllers/main.py index 37a73fdc..a3aaf280 100644 --- a/connect/controllers/main.py +++ b/connect/controllers/main.py @@ -61,11 +61,3 @@ def _serve_media(self, media_url): return res else: raise UserError("Failed to download the media. Status code: %s" % response.status_code) - - @http.route('/connect//', methods=['GET', 'POST'], type='http', auth='public', csrf=False) - def health_check(self, uid): - instance_uid = http.request.env['connect.settings'].sudo().get_param('instance_uid') - if uid == instance_uid: - return "True" - else: - return "False" diff --git a/connect/data/data.xml b/connect/data/data.xml deleted file mode 100644 index 024e02dd..00000000 --- a/connect/data/data.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/connect/data/functions.xml b/connect/data/functions.xml deleted file mode 100644 index 50add3d5..00000000 --- a/connect/data/functions.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/connect/data/license.xml b/connect/data/license.xml new file mode 100644 index 00000000..21a98631 --- /dev/null +++ b/connect/data/license.xml @@ -0,0 +1,20 @@ + + + + + + + oduist_license_server + https://license.oduist.com + + + + + diff --git a/connect/i18n/fi.po b/connect/i18n/fi.po index 1cec970e..971f9cb8 100644 --- a/connect/i18n/fi.po +++ b/connect/i18n/fi.po @@ -181,9 +181,9 @@ msgid "Admin" msgstr "Ylläpitäjä" #. module: connect -#: model:ir.model.fields,field_description:connect.field_connect_settings__admin_email -msgid "Admin Email" -msgstr "Ylläpitäjän sähköposti" +#: model:ir.model.fields,field_description:connect.field_connect_settings__subscribe_email +msgid "Subscribe Email" +msgstr "Ilmoitusten sähköposti" #. module: connect #: model:ir.model.fields,field_description:connect.field_connect_settings__admin_name @@ -1313,44 +1313,6 @@ msgstr "Vihjeitä" msgid "History" msgstr "Historia" -#. module: connect -#: model:ir.model.fields,field_description:connect.field_connect_settings__i_agree_to_contact -msgid "I Agree To Contact" -msgstr "Hyväksyn yhteydenoton" - -#. module: connect -#: model:ir.model.fields,field_description:connect.field_connect_settings__i_agree_to_receive -msgid "I Agree To Receive" -msgstr "Suostun vastaanottamaan" - -#. module: connect -#: model:ir.model.fields,field_description:connect.field_connect_settings__i_agree_to_register -msgid "I Agree To Register" -msgstr "Hyväksyn rekisteröinnin" - -#. module: connect -#: model_terms:ir.ui.view,arch_db:connect.connect_settings_form -msgid "" -"I agree to be contacted by sales representatives who can help me understand additional services and support options " -"available for my installation." -msgstr "" -"Suostun siihen, että myyntiedustajat ottavat minuun yhteyttä ja auttavat minua ymmärtämään asennukseeni saatavilla olevia " -"lisäpalveluja ja tukivaihtoehtoja." - -#. module: connect -#: model_terms:ir.ui.view,arch_db:connect.connect_settings_form -msgid "I agree to create an account in the Oduist users portal to receive free technical support from Oduist team." -msgstr "Suostun luomaan tilin Oduistin käyttäjäportaaliin saadakseni ilmaista teknistä tukea Oduistin tiimiltä." - -#. module: connect -#: model_terms:ir.ui.view,arch_db:connect.connect_settings_form -msgid "" -"I agree to receive important product notifications, including security advisories, major updates, and critical patches " -"that may affect my installation." -msgstr "" -"Suostun vastaanottamaan tärkeitä tuoteilmoituksia, mukaan lukien tietoturvaohjeet, suuret päivitykset ja kriittiset " -"korjaukset, jotka voivat vaikuttaa asennukseeni." - #. module: connect #: model:ir.model.fields,field_description:connect.field_connect_call__id #: model:ir.model.fields,field_description:connect.field_connect_callflow__id @@ -1442,11 +1404,6 @@ msgstr "Syötetyyppi" msgid "Installation Date" msgstr "Asennuspäivä" -#. module: connect -#: model:ir.model.fields,field_description:connect.field_connect_settings__instance_uid -msgid "Instance UID" -msgstr "Instanssin UID" - #. module: connect #: model:ir.model.fields,field_description:connect.field_connect_callflow__invalid_input_message #: model:ir.model.fields,field_description:connect.field_connect_callout__invalid_input_message @@ -1460,11 +1417,6 @@ msgstr "Virheellinen syöttöviesti" msgid "Is Follower" msgstr "On seuraaja" -#. module: connect -#: model:ir.model.fields,field_description:connect.field_connect_settings__is_registered -msgid "Is Registered" -msgstr "On rekisteröity" - #. module: connect #. odoo-javascript #: code:addons/connect/static/src/components/phone/phone/phone.xml:0 diff --git a/connect/migrations/0.6/post-migrate.py b/connect/migrations/0.6/post-migrate.py deleted file mode 100644 index 024f171e..00000000 --- a/connect/migrations/0.6/post-migrate.py +++ /dev/null @@ -1,34 +0,0 @@ -import logging -from odoo import api -from odoo.api import SUPERUSER_ID -from odoo.tools.sql import rename_column - -logger = logging.getLogger(__name__) - - -def migrate(cr, version): - env = api.Environment(cr, SUPERUSER_ID, {}) - # Merge defaults. - for field in ['admin_name', 'admin_phone', 'admin_email', 'web_base_url']: - default_value = env['connect.settings'].get_param(field) - if not default_value and field == 'admin_phone': - default_value = '1234567890' - elif not default_value and field == 'admin_email': - default_value = 'admin@example.com' - env['connect.settings'].set_param( - field, default_value) - logger.info('Connect settings migrated.') - # Sync numbers - if not env['connect.number'].search([]): - logger.info('No DID numbers to migrate.') - return - # Sync outgoing calleid numbers - env['connect.outgoing_callerid'].sync() - # Copy numbers - for user in env['connect.user'].search([]): - if user.callerid_number: - callerid = env['connect.outgoing_callerid'].search([ - ('number', '=', user.callerid_number.phone_number)]) - if callerid: - user.outgoing_callerid = callerid - logger.info('CallerId %s for user %s migrated.', callerid.number, user.name) diff --git a/connect/migrations/0.7/post-migrate.py b/connect/migrations/0.7/post-migrate.py deleted file mode 100644 index 6dbbd348..00000000 --- a/connect/migrations/0.7/post-migrate.py +++ /dev/null @@ -1,13 +0,0 @@ -from odoo.tools.sql import rename_column -from odoo import api -from odoo.api import SUPERUSER_ID - - -def migrate(cr, version): - env = api.Environment(cr, SUPERUSER_ID, {}) - print('Assigning partners to recordings from calls...') - recs = env['connect.recording'].search([]) - for rec in recs: - if rec.call.partner: - rec.partner = rec.call.partner - print('Migration done.') diff --git a/connect/migrations/0.8/post-migrate.py b/connect/migrations/0.8/post-migrate.py deleted file mode 100644 index 139120c4..00000000 --- a/connect/migrations/0.8/post-migrate.py +++ /dev/null @@ -1,18 +0,0 @@ -from odoo import api -from odoo.api import SUPERUSER_ID - - -def migrate(cr, version): - env = api.Environment(cr, SUPERUSER_ID, {}) - # Reset subscription - env['connect.settings'].set_param('is_registered', False) - # Remove API URL is it's based on the region now. - env['ir.config_parameter'].search([('key', '=', 'connect.api_url')]).unlink() - # Reset the key for new subscription process. - env['ir.config_parameter'].search([('key', '=', 'connect.api_key')]).unlink() - settings = env['connect.settings'].search([], limit=1) - protected_fields = ['auth_token', 'twilio_api_secret', 'openai_api_key'] - for field_name in protected_fields: - if settings.get_param(field_name): - settings.set_param('display_{}'.format(field_name), settings.get_param(field_name)) - print('Migration done.') diff --git a/connect/migrations/0.9/pre-migrate.py b/connect/migrations/0.9/pre-migrate.py deleted file mode 100644 index 8c96141f..00000000 --- a/connect/migrations/0.9/pre-migrate.py +++ /dev/null @@ -1,23 +0,0 @@ -import logging -from odoo import api -from odoo.api import SUPERUSER_ID -from odoo.tools.sql import rename_column - -logger = logging.getLogger(__name__) - -def check_for_column(env, table_name, column_name): - query = """ - SELECT column_name - FROM information_schema.columns - WHERE table_name=%s AND column_name=%s; - """ - env.cr.execute(query, (table_name, column_name)) - result = env.cr.fetchone() - return bool(result) - -def migrate(cr, version): - print('Migrating CallFlow prompt message...') - env = api.Environment(cr, SUPERUSER_ID, {}) - if check_for_column(env, 'connect_callflow', 'gather_prompt_message'): - rename_column(cr, 'connect_callflow', 'gather_prompt_message', 'prompt_message') - diff --git a/connect/migrations/1.0.1/pre-migrate.py b/connect/migrations/1.0.1/pre-migrate.py deleted file mode 100644 index 705a0673..00000000 --- a/connect/migrations/1.0.1/pre-migrate.py +++ /dev/null @@ -1,12 +0,0 @@ -import logging -from odoo import api -from odoo.api import SUPERUSER_ID -from odoo.tools.sql import rename_column - -logger = logging.getLogger(__name__) - -def migrate(cr, version): - print('Migrating to Webhook User...') - env = api.Environment(cr, SUPERUSER_ID, {}) - env['res.users'].search([('login', '=', 'connect')]).unlink() - diff --git a/connect/migrations/1.0.5/post-migrate.py b/connect/migrations/1.0.5/post-migrate.py deleted file mode 100644 index c589002a..00000000 --- a/connect/migrations/1.0.5/post-migrate.py +++ /dev/null @@ -1,20 +0,0 @@ -from odoo import api -from odoo.api import SUPERUSER_ID - - -def migrate(cr, version): - env = api.Environment(cr, SUPERUSER_ID, {}) - for user in env['connect.user'].search([]): - # Reset SIP - if user.sip_enabled: - user.sip_enabled = False - user.sip_enabled = True - # Reset Client - if user.client_enabled: - user.client_enabled = False - user.client_enabled = True - # Reset Voicemail - if user.voicemail_enabled: - user.voicemail_enabled = False - user.voicemail_enabled = True - print('Connect app migration is done.') diff --git a/connect/migrations/1.0.6/post-migrate.py b/connect/migrations/1.0.6/post-migrate.py deleted file mode 100644 index fbcca50e..00000000 --- a/connect/migrations/1.0.6/post-migrate.py +++ /dev/null @@ -1,8 +0,0 @@ -from odoo import api, SUPERUSER_ID - - -def migrate(cr, version): - env = api.Environment(cr, SUPERUSER_ID, {}) - # Reset admin name to migrate registration fields. - env['connect.settings'].set_param('admin_name', '') - print('Connect app migration is done.') \ No newline at end of file diff --git a/connect/models/__init__.py b/connect/models/__init__.py index 7af4e4dd..fb9c5674 100644 --- a/connect/models/__init__.py +++ b/connect/models/__init__.py @@ -1,10 +1,22 @@ +# -*- coding: utf-8 -*- +""" +ODUIST PROPRIETARY LICENSE +Copyright (c) 2025 Oduist + +This file contains license validation logic. +Modification is prohibited under Oduist Proprietary License. +See LICENSE and COPYRIGHT files for full terms. +""" + from . import call from . import callflow +from . import ir_module_module from . import channel from . import debug from . import domain from . import exten from . import favorite +from . import license from . import mail from . import message from . import message_configuration diff --git a/connect/models/call.py b/connect/models/call.py index 825a9ee4..8d95e636 100644 --- a/connect/models/call.py +++ b/connect/models/call.py @@ -1,4 +1,12 @@ # -*- coding: utf-8 -*- +""" +ODUIST PROPRIETARY LICENSE +Copyright (c) 2025 Oduist + +This file contains license validation logic. +Modification is prohibited under Oduist Proprietary License. +See LICENSE and COPYRIGHT files for full terms. +""" import json import logging @@ -9,7 +17,8 @@ from odoo import fields, models, api, release, SUPERUSER_ID, tools from odoo.exceptions import ValidationError from twilio.twiml.voice_response import VoiceResponse, Say, Dial, Conference, Client, Number, Sip -from .settings import debug +from .settings import debug, MAX_EXTEN_LEN +from .res_partner import strip_number logger = logging.getLogger(__name__) @@ -162,6 +171,8 @@ def on_call_status(self, params): if not channel: logger.error('No channel returned from on_call_status!') return False + if not self.env['oduist.license'].check_license('connect', silent=True): + return False if not channel.parent_channel and not channel.call: # Create a new call. if channel.technical_direction == 'outbound-api': @@ -286,14 +297,14 @@ def save_call_price(self, call, params): if not call_sid: debug(self, 'No CallSid in webhook params, cannot store for price fetching') return - + # Store CallSid in call record for later price fetching by cron call.write({ 'call_sid': call_sid, 'is_price_fetched': False, }) debug(self, f'Marked call {call.id} (CallSid: {call_sid}) for price fetching by cron job') - + except Exception as e: logger.error(f'Error in save_call_price: {e}') @@ -336,7 +347,7 @@ def fetch_call_prices_batch(self): if not self.env['connect.settings'].sudo().get_param('fetch_call_prices'): debug(self, 'Call price fetching is disabled in settings') return - + # Find calls that need price fetching (completed calls without price) calls_to_fetch = self.search([ ('is_price_fetched', '=', False), @@ -344,9 +355,9 @@ def fetch_call_prices_batch(self): ('status', 'in', CALL_END_STATUSES), ('create_date', '>=', fields.Datetime.now() - timedelta(days=30)) # Only last 30 days ]) - + debug(self, f'Found {len(calls_to_fetch)} calls needing price fetch') - + for call in calls_to_fetch: try: success = self._fetch_call_price_from_api(call, call.call_sid) @@ -357,7 +368,7 @@ def fetch_call_prices_batch(self): debug(self, f'Price not yet available for call {call.id}, will retry next time') except Exception as e: logger.error(f'Error fetching price for call {call.id}: {e}') - + debug(self, f'Batch price fetch completed') def register_call(self, channel, params): @@ -523,10 +534,108 @@ def transfer_user(): def redial(self): self.ensure_one() - self.env['connect.settings'].originate_call( + self.originate_call( number=self.called if self.direction == 'outgoing' else self.caller, ) + @api.model + def originate_call( + self, number, res_model=None, res_id=None, user=None, whatsapp_call=False + ): + number = strip_number(number) + if len(number) > MAX_EXTEN_LEN: + number = "+{}".format(number) + client = self.env['connect.settings'].get_client() + partner_id = False + obj = self.env[res_model].browse(res_id) if res_model and res_id else False + caller_name = "" + if res_model == "res.partner" and obj: + partner_id = res_id + caller_name = obj.display_name + elif obj and hasattr(obj, "partner_id") and obj.partner_id: + partner_id = obj.partner_id.id + caller_name = obj.partner_id.display_name + elif obj and hasattr(obj, "partner") and obj.partner: + partner_id = obj.partner.id + caller_name = obj.partner.display_name + if not user: + user = self.env.user + if not user.connect_user: + raise ValidationError("User does not have a SIP username defined!") + first_flow = self.env["connect.user_callflow"].search( + [("user", "=", user.id), ("callflow_type", "in", ["client", "sip"])], + order="prio", + limit=1, + ) + if first_flow.callflow_type == "sip": + to = self.env['connect.settings'].compute_sip_uri(user) + else: + to = "client:{}?autoAnswer=yes&Partner={}&CallerName={}".format( + self.env.user.connect_user.uri, partner_id or "", caller_name or "" + ) + if "client:" in to: + to += "&From={}".format((number or "").replace("+", "")) + self.env["oduist.license"].check_license("connect", silent=False) + exten = self.env["connect.exten"].search([("number", "=", number)], limit=1) + api_url = self.env['connect.settings'].sudo().get_param("api_url") + edge = self.env["connect.settings"].get_param("twilio_edge") + status_url = urljoin(api_url, "twilio/webhook/callstatus#e={}".format(edge)) + if exten: + callerId = user.connect_user.exten.number + twiml = exten.render() + else: + if whatsapp_call: + pbx_user = user.connect_user + sender = self.env["connect.whatsapp_sender"].get_default_sender( + pbx_user + ) + caller_number = sender.number if sender else False + if not caller_number: + raise ValidationError("You must configure a WhatsApp sender!") + callerId = f"whatsapp:{caller_number}" + twiml = """ + + + {} + +""".format(callerId, status_url, number) + else: + default_number = self.env["connect.outgoing_callerid"].search( + [("is_default", "=", True)], limit=1 + ) + if user.connect_user.outgoing_callerid: + callerId = user.connect_user.outgoing_callerid.number + else: + callerId = default_number.number + twiml = self.env['connect.settings'].get_external_call_route(number, callerId, status_url) + record = self.env.user.connect_user.record_calls + record_status_url = urljoin( + api_url, "twilio/webhook/recordingstatus#e={}".format(edge) + ) + debug(self, "Originate destination TwiML: {}".format(twiml)) + channel = client.calls.create( + twiml=twiml, + to=to, + from_=callerId, + status_callback=status_url, + record=record, + recording_channels="dual", + recording_status_callback=record_status_url, + recording_status_callback_event=["completed"], + status_callback_event=["initiated", "answered", "completed"], + ) + self.env["connect.channel"].sudo().create( + { + "sid": channel.sid, + "technical_direction": "outboubd-api", + "caller_user": user.id, + "caller_pbx_user": user.connect_user.id, + "partner": partner_id, + "called": number, + "caller": callerId, + } + ) + @api.model def get_widget_calls(self, domain, limit=None, offset=0, order='id desc', fields=[]): calls = self.search(domain, offset, limit, order) diff --git a/connect/models/domain.py b/connect/models/domain.py index 1de74bb1..c3363a38 100644 --- a/connect/models/domain.py +++ b/connect/models/domain.py @@ -1,4 +1,12 @@ # -*- coding: utf-8 -*- +""" +ODUIST PROPRIETARY LICENSE +Copyright (c) 2025 Oduist + +This file contains license validation logic. +Modification is prohibited under Oduist Proprietary License. +See LICENSE and COPYRIGHT files for full terms. +""" import logging import re @@ -507,6 +515,8 @@ def sync(self): @api.model def route_call(self, request, params={}): debug(self, "Domain call to %s" % request.get("To")) + if not self.env["oduist.license"].check_license('connect'): + return "This is Oduist Connect. Your trial period is over. Please buy a license to continue." # Create call + channel self.env["connect.call"].on_call_status(request) to_val = request.get("To") or '' diff --git a/connect/models/ir_module_module.py b/connect/models/ir_module_module.py new file mode 100644 index 00000000..56674d2d --- /dev/null +++ b/connect/models/ir_module_module.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +""" +ODUIST PROPRIETARY LICENSE +Copyright (c) 2025 Oduist + +This file contains license validation logic. +Modification is prohibited under Oduist Proprietary License. +See LICENSE and COPYRIGHT files for full terms. +""" + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class IrModuleModule(models.Model): + _inherit = "ir.module.module" + + oduist_license_status = fields.Char( + string="License Status", + compute="_compute_oduist_license_status", + ) + + oduist_module_purchased = fields.Boolean( + string="Module Purchased", + compute="_compute_oduist_license_status", + ) + + oduist_module_price = fields.Char( + string="Module Price", + readonly=True, + ) + + oduist_module_show_price = fields.Char( + string="Price", + compute="_compute_oduist_license_status", + ) + + def _compute_oduist_license_status(self): + License = self.env["oduist.license"] + for rec in self: + status = License.get_license_status(rec.name) + + if status["status"] == "production": + order_id = status.get("order_id", "") + rec.oduist_license_status = f"Purchase Order: {order_id}" + rec.oduist_module_purchased = True + rec.oduist_module_show_price = "" + elif status["status"] == "trial_active": + rec.oduist_license_status = f"Trial: {status['days_left']} days left" + rec.oduist_module_purchased = False + rec.oduist_module_show_price = rec.oduist_module_price or "" + elif status["status"] == "trial_expired": + rec.oduist_license_status = "Trial Expired" + rec.oduist_module_purchased = False + rec.oduist_module_show_price = rec.oduist_module_price or "" + elif status["status"] == "demo": + rec.oduist_license_status = "Demo" + rec.oduist_module_purchased = True + rec.oduist_module_show_price = "" + + def buy_oduist_license(self): + """Buy license for this specific module.""" + self.ensure_one() + License = self.env["oduist.license"] + token = License.sudo().get_param("license_token") + if not token: + License.update_license_status() + return License.buy_licenses([self.name]) diff --git a/connect/models/license.py b/connect/models/license.py new file mode 100644 index 00000000..131a5b34 --- /dev/null +++ b/connect/models/license.py @@ -0,0 +1,500 @@ +# -*- coding: utf-8 -*- +""" +ODUIST PROPRIETARY LICENSE +Copyright (c) 2025 Oduist + +This file contains license validation logic. +Modification is prohibited under Oduist Proprietary License. +See LICENSE and COPYRIGHT files for full terms. +""" + +import logging +import uuid +from datetime import datetime, timedelta +from urllib.parse import urljoin + +import jwt +import requests +from odoo import api, fields, models, release +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + +PUBLIC_KEY_PARAM = "oduist_license.public_key" +ODUIST_MODULES = [] + + +def rpc(url: str, request_data: dict) -> dict: + """Perform a JSON-RPC call to the specified URL.""" + payload = { + "jsonrpc": "2.0", + "method": "call", + "params": request_data, + } + try: + res = requests.post(url, json=payload, timeout=30) + res.raise_for_status() + return res.json().get("result", {}) + except requests.exceptions.RequestException as e: + return {"error": f"Request failed: {str(e)}"} + except Exception as e: + return {"error": f"Unexpected error: {str(e)}"} + + +class OduistLicense(models.Model): + """ + License configuration and validation for Oduist modules. + Single-record model storing license data and subscription preferences. + """ + + _name = "oduist.license" + _description = "License Configuration" + + instance_uid = fields.Char( + string="Instance UID", + help="Unique identifier for this Odoo instance", + readonly=True, + ) + license_token = fields.Text( + string="License Token", + groups="base.group_erp_manager", + help="JWT license token from oduist.com. Leave empty to use 30-day trial.", + ) + registration_number = fields.Char( + string="Registration Number", + help="Registration number from license server", + ) + subscribe_email = fields.Char( + help="Email for subscription notifications" + ) + subscribe_to_security_alerts = fields.Boolean( + string="Subscribe to Critical Security Alerts", + help="Receive immediate notifications regarding critical security vulnerabilities " + "or urgent issues with your installed modules." + ) + subscribe_to_onboarding = fields.Boolean( + string="Subscribe to Personalized Onboarding Support", + help="Receive step-by-step guidance for system setup. Usage data is analyzed " + "to provide relevant tips and streamline your configuration process " + "based on active features." + ) + subscribe_to_updates = fields.Boolean( + string="Subscribe to Product News & AI Tips and Tricks", + help="Stay updated on new features, product releases, and the latest AI " + "advancements within the Odoo ecosystem." + ) + name = fields.Char(compute="_get_name", store=False) + oduist_modules = fields.Many2many( + "ir.module.module", + string="Oduist Modules", + compute="_compute_oduist_modules", + ) + all_modules_purchased = fields.Boolean( + compute="_compute_oduist_modules", + ) + + @api.model_create_multi + def create(self, vals_list): + """Auto-generate instance_uid on first record creation""" + for vals in vals_list: + if not vals.get("instance_uid"): + vals["instance_uid"] = str(uuid.uuid4()) + return super().create(vals_list) + + def write(self, vals): + """Update license status when agreement fields change""" + res = super().write(vals) + agreement_fields = { + "subscribe_to_security_alerts", + "subscribe_to_onboarding", + "subscribe_to_updates", + "subscribe_email", + } + if any(field in vals for field in agreement_fields): + self.sudo().update_license_status(raise_exc=False) + return res + + def _get_name(self): + """Compute display name""" + for rec in self: + rec.name = "License Configuration" + + def _compute_oduist_modules(self): + """Compute oduist modules and check if all are purchased""" + for rec in self: + modules = ( + self.env["ir.module.module"] + .sudo() + .search([("name", "in", ODUIST_MODULES), ("state", "=", "installed")]) + ) + rec.oduist_modules = modules + rec.all_modules_purchased = all( + m.oduist_module_purchased for m in modules + ) + + @api.model + def get_param(self, param, default=False): + """Get parameter value from single record. Creates record if not exists.""" + data = self.search([]) + if not data: + data = self.sudo().with_context(no_constrains=True).create({}) + else: + data = data[0] + if not data: + return default + value = getattr(data, param, None) + return value if value is not None else default + + @api.model + def set_param(self, param, value): + """Set parameter value in single record. Creates record if not exists.""" + data = self.search([]) + if not data: + data = self.sudo().with_context(no_constrains=True).create({param: value}) + else: + data[0].write({param: value}) + + @api.model + def open_license_form(self): + """Open license configuration form.""" + rec = self.search([]) + if not rec: + rec = self.sudo().with_context(no_constrains=True).create({}) + else: + rec = rec[0] + return { + "type": "ir.actions.act_window", + "res_model": "oduist.license", + "res_id": rec.id, + "name": "License Configuration", + "views": [[self.env.ref(self._module + ".oduist_license_form").id, "form"]], + "target": "current", + } + + @api.model + def _get_license_token(self): + """Get license token from this record""" + return self.sudo().get_param("license_token", default="") + + @api.model + def _get_public_key(self): + """Get public key from system parameters""" + return ( + self.env["ir.config_parameter"] + .sudo() + .get_param(PUBLIC_KEY_PARAM, default="") + ) + + @api.model + def validate_token(self, token): + """ + Validate JWT token with RS256 algorithm. + + Returns: + dict: Decoded payload if valid + None: If token is invalid + """ + if not token: + return None + + public_key = self._get_public_key() + if not public_key: + _logger.warning("Public key not configured") + return None + + try: + payload = jwt.decode( + token, + public_key, + algorithms=["RS256"], + options={"verify_signature": True}, + ) + + instance_uid = self.sudo().get_param("instance_uid") + if payload.get("instance_uid") != instance_uid: + _logger.warning( + "Token instance UID mismatch. Expected: %s, Got: %s", + instance_uid, + payload.get("instance_uid"), + ) + return None + + return payload + + except jwt.ExpiredSignatureError: + _logger.warning("Token signature expired") + return None + except jwt.InvalidTokenError as e: + _logger.warning("Invalid token: %s", str(e)) + return None + except Exception as e: + _logger.error("Error validating token: %s", str(e)) + return None + + @api.model + def is_trial_valid(self, module_name): + module = ( + self.env["ir.module.module"] + .sudo() + .search([("name", "=", module_name), ("state", "=", "installed")], limit=1) + ) + if not module: + return False, 0 + install_date = module.create_date or datetime.now() - timedelta(days=30) + now = datetime.now() + days_passed = (now - install_date).days + days_left = 30 - days_passed + is_valid = days_left > 0 + return is_valid, max(0, days_left) + + @api.model + def get_license_status(self, module_name): + token = self._get_license_token() + if token: + payload = self.validate_token(token) + if payload: + if payload.get("instance_type") == "demo": + return {"status": "demo"} + purchased_modules = payload.get("purchased_modules", {}) + if module_name in purchased_modules: + module_info = purchased_modules[module_name] + order_id = module_info.get("order_id", "") + return { + "status": module_info.get("license_type"), + "order_id": order_id, + } + is_valid, days_left = self.is_trial_valid(module_name) + if is_valid: + return { + "status": "trial_active", + "days_left": days_left, + } + else: + return { + "status": "trial_expired", + "days_left": 0, + } + + @api.model + def get_oduist_license_banner(self): + """ + Get license banner info for installed Oduist modules. + Priority: trial_expired > trial_active > demo + Returns: dict with module_name, status, message, type + """ + installed_oduist_modules = ( + self.env["ir.module.module"] + .sudo() + .search([("name", "in", ODUIST_MODULES), ("state", "=", "installed")]) + .mapped("name") + ) + if not installed_oduist_modules: + return None + trial_expired_module = None + trial_active_module = None + demo_module = None + for module_name in installed_oduist_modules: + status = self.get_license_status(module_name) + if status["status"] == "trial_expired" and not trial_expired_module: + trial_expired_module = (module_name, status) + elif status["status"] == "trial_active" and not trial_active_module: + trial_active_module = (module_name, status) + elif status["status"] == "demo" and not demo_module: + demo_module = (module_name, status) + selected_module = None + if trial_expired_module: + selected_module = trial_expired_module + message_type = "danger" + message = f"Oduist {selected_module[0].replace('_', ' ').title()}: Buy a license to continue" + elif trial_active_module: + selected_module = trial_active_module + days_left = selected_module[1].get("days_left", 0) + message_type = "warning" if days_left <= 7 else "info" + message = f"Oduist {selected_module[0].replace('_', ' ').title()} Trial: {days_left} days remaining" + elif demo_module: + selected_module = demo_module + message_type = "warning" + message = f"Oduist {selected_module[0].replace('_', ' ').title()}: Demo License" + if not selected_module: + return None + return { + "module_name": selected_module[0], + "status": selected_module[1]["status"], + "message": message, + "type": message_type, + } + + @api.model + def check_license(self, module_name, silent=True): + status = self.sudo().get_license_status(module_name) + is_valid = status["status"] != "trial_expired" + if not is_valid and not silent: + raise ValidationError("Module {} trial period has expired! Please buy a license to continue.".format(module_name)) + elif not is_valid and silent: + _logger.warning("Module {} trial period has expired! Please buy a license to continue".format(module_name)) + return False + else: + return True + + @api.model + def update_license_status(self, raise_exc=True): + """ + Check license status with license server. + Updates license_token, registration_number and subscription data. + """ + base_url = ( + self.env["ir.config_parameter"].sudo().get_param("oduist_license_server") + ) + if not base_url: + raise ValidationError("License server URL not configured!") + + instance_uid = self.sudo().get_param("instance_uid") + if not instance_uid: + raise ValidationError("Instance UID not configured!") + License = self.env["oduist.license"].sudo() + ICP = self.env["ir.config_parameter"].sudo() + + subscribe_to_security_alerts = License.get_param("subscribe_to_security_alerts", default=False) + subscribe_to_onboarding = License.get_param("subscribe_to_onboarding", default=False) + subscribe_to_updates = License.get_param("subscribe_to_updates", default=False) + + # Get main company (first by ID) + main_company = self.env["res.company"].search([], order="id", limit=1) + country_code = main_company.country_id.code if main_company and main_company.country_id else None + + request_data = { + "instance_uid": instance_uid, + "odoo_version": release.version_info[0], + } + + if country_code: + request_data["country_code"] = country_code + + if subscribe_to_security_alerts: + request_data["subscribe_to_security_alerts"] = True + if subscribe_to_onboarding: + request_data["subscribe_to_onboarding"] = True + if subscribe_to_updates: + request_data["subscribe_to_updates"] = True + if subscribe_to_security_alerts or subscribe_to_onboarding or subscribe_to_updates: + subscribe_email = License.get_param("subscribe_email", default="") + if subscribe_email: + request_data["subscribe_email"] = subscribe_email + + license_check_url = urljoin(base_url, "/license/v2/check") + response = rpc(license_check_url, request_data) + if response.get("error"): + error_msg = response.get("error") + _logger.debug('License check request failed: %s', error_msg) + if raise_exc: + raise ValidationError(error_msg) + + if not response and raise_exc: + raise ValidationError("License check failed: empty response") + if response.get("token"): + License.set_param("license_token", response.get("token")) + ICP.set_param(PUBLIC_KEY_PARAM, response.get("public_key")) + token_data = self.validate_token(response.get("token")) + if token_data and token_data.get("registration_number"): + License.set_param( + "registration_number", token_data.get("registration_number") + ) + modules_data = response.get("modules", {}) + for module_name, module_info in modules_data.items(): + if module_name in ODUIST_MODULES: + module = ( + self.env["ir.module.module"] + .sudo() + .search( + [("name", "=", module_name), ("state", "=", "installed")], + limit=1, + ) + ) + if module: + vals = {} + if module_info.get("latest_version"): + vals["latest_version"] = module_info.get("latest_version") + if module_info.get("price"): + vals["oduist_module_price"] = module_info.get("price") + if vals: + module.write(vals) + else: + error_msg = f"License check failed: {response}" + _logger.warning(error_msg) + if raise_exc: + raise ValidationError(error_msg) + + def buy_all_licenses(self): + """Initiate purchase process for all Oduist modules (excluding already purchased).""" + token = self.sudo().get_param("license_token") + if not token: + self.update_license_status() + token = self.sudo().get_param("license_token") + token_data = self.validate_token(token) if token else None + purchased_modules = token_data.get("purchased_modules", {}) if token_data else {} + modules_to_buy = [m for m in ODUIST_MODULES if m not in purchased_modules] + if not modules_to_buy: + raise ValidationError("All modules are already purchased!") + return self.buy_licenses(modules_to_buy) + + @api.model + def buy_licenses(self, module_list): + """ + Initiate purchase process for specified modules. + + Args: + module_list: List of module names to purchase + + Returns: + ir.actions.act_url action to open payment link + """ + base_url = ( + self.env["ir.config_parameter"].sudo().get_param("oduist_license_server") + ) + if not base_url: + raise ValidationError("License server URL not configured!") + + instance_uid = self.sudo().get_param("instance_uid") + if not instance_uid: + raise ValidationError("Instance UID not configured!") + # Get main company (first by ID) + main_company = self.env["res.company"].search([], order="id", limit=1) + country_code = main_company.country_id.code if main_company and main_company.country_id else None + + request_data = { + "instance_uid": instance_uid, + "modules": module_list, + } + + if main_company: + if main_company.vat: + request_data["vat_number"] = main_company.vat + if main_company.name: + request_data["vat_company_name"] = main_company.name + if main_company.street: + request_data["vat_street"] = main_company.street + if main_company.city: + request_data["vat_city"] = main_company.city + if main_company.state_id and main_company.state_id.name: + request_data["vat_state"] = main_company.state_id.name + if country_code: + request_data["vat_country"] = country_code + if main_company.zip: + request_data["vat_postcode"] = main_company.zip + + license_buy_url = urljoin(base_url, "/license/v2/buy") + response = rpc(license_buy_url, request_data) + if response.get("error"): + raise ValidationError(response.get("error")) + + if response and response.get("payment_link"): + _logger.info(f"License buy result: {response}") + return { + "type": "ir.actions.act_url", + "url": response["payment_link"], + "target": "new", + } + else: + error_msg = f"License buy failed: {response}" + _logger.warning(error_msg) + raise ValidationError(error_msg) diff --git a/connect/models/message.py b/connect/models/message.py index 4496e45c..43a01db2 100644 --- a/connect/models/message.py +++ b/connect/models/message.py @@ -1,3 +1,13 @@ +# -*- coding: utf-8 -*- +""" +ODUIST PROPRIETARY LICENSE +Copyright (c) 2025 Oduist + +This file contains license validation logic. +Modification is prohibited under Oduist Proprietary License. +See LICENSE and COPYRIGHT files for full terms. +""" + import ast import logging @@ -194,6 +204,8 @@ def get_receive_message_values(self, params): @api.model def receive(self, params): + if not self.env['oduist.license'].check_license('connect', silent=True): + return str(MessagingResponse()) try: if params.get('AccountSid') != self.env['connect.settings'].get_param('account_sid'): logger.warning("Received Twilio SMS webhook with incorrect AccountSid") @@ -326,6 +338,7 @@ def receive(self, params): return str(MessagingResponse()) # Return empty TwiML response, i.e. no reply. def send(self, recipient, body, res_id=None, res_model=None, outgoing_callerid=None): + self.env['oduist.license'].check_license('connect', silent=False) sender_user = self.env.user message_data = { 'message_type': 'WhatsApp', diff --git a/connect/models/number.py b/connect/models/number.py index 03a13829..70e5e9db 100644 --- a/connect/models/number.py +++ b/connect/models/number.py @@ -1,4 +1,12 @@ # -*- coding: utf-8 -*- +""" +ODUIST PROPRIETARY LICENSE +Copyright (c) 2025 Oduist + +This file contains license validation logic. +Modification is prohibited under Oduist Proprietary License. +See LICENSE and COPYRIGHT files for full terms. +""" import json import logging @@ -152,6 +160,8 @@ def sync(self): def render(self, request={}, params={}): self.ensure_one() + if not self.env["oduist.license"].check_license('connect'): + return "This is Oduist Connect. Your trial period is over. Please buy a license to continue." if self.destination == 'twiml' and self.twiml: return self.twiml.render(request) elif self.destination == 'user' and self.user: diff --git a/connect/models/res_partner.py b/connect/models/res_partner.py index 47e5aef1..7aa09ce0 100644 --- a/connect/models/res_partner.py +++ b/connect/models/res_partner.py @@ -2,20 +2,12 @@ import logging import phonenumbers -import re from phonenumbers import phonenumberutil from odoo import models, fields, api, release -from .settings import debug +from .settings import debug, strip_number logger = logging.getLogger(__name__) -def strip_number(number): - """Strip number formating""" - if not isinstance(number, str): - return number - pattern = r'[\s\(\)\-\+]' - return re.sub(pattern, '', number).lstrip('0') - def format_number(self, number, country=None, format_type='e164'): """Return number in requested format_type diff --git a/connect/models/settings.py b/connect/models/settings.py index c797ca71..8ebd3c0c 100644 --- a/connect/models/settings.py +++ b/connect/models/settings.py @@ -1,30 +1,36 @@ # -*- coding: utf-8 -*- +""" +ODUIST PROPRIETARY LICENSE +Copyright (c) 2025 Oduist + +This file contains license validation logic. +Modification is prohibited under Oduist Proprietary License. +See LICENSE and COPYRIGHT files for full terms. +""" + import inspect import json import logging -from multiprocessing import RLock import os -import secrets - -import httpx -import openai -import requests import random import re import string from urllib.parse import urljoin -import uuid -from odoo import fields, models, api, release -from odoo.exceptions import ValidationError, UserError + +import httpx +import openai +from odoo import api, fields, models, release +from odoo.exceptions import ValidationError from twilio.rest import Client +from odoo.addons.connect.models.license import ODUIST_MODULES +ODUIST_MODULES.append('connect') logger = logging.getLogger(__name__) TWILIO_LOG_LEVEL = logging.WARNING -############### SETTINGS ##################################### -MODULE_NAME = "connect" MAX_EXTEN_LEN = 4 + PROTECTED_FIELDS = [ "display_auth_token", "display_region_auth_token", @@ -33,14 +39,14 @@ ] TWILIO_EDGES = [ - ('ashburn', 'US East Coast (Virginia)'), - ('umatilla', 'US West Coast (Oregon)'), - ('dublin', 'Ireland'), - ('frankfurt', 'Frankfurt'), - ('sydney', 'Australia'), - ('sao-paulo', 'Brazil'), - ('tokyo', 'Japan'), - ('singapore', 'Singapore'), + ("ashburn", "US East Coast (Virginia)"), + ("umatilla", "US West Coast (Oregon)"), + ("dublin", "Ireland"), + ("frankfurt", "Frankfurt"), + ("sydney", "Australia"), + ("sao-paulo", "Brazil"), + ("tokyo", "Japan"), + ("singapore", "Singapore"), ] @@ -76,23 +82,24 @@ def format_connect_response(text): def generate_password(): + special_chars = "@!#$%^&*" characters = [ random.choice(string.ascii_lowercase), random.choice(string.ascii_uppercase), random.choice(string.digits), + random.choice(special_chars), ] - characters += random.choices(string.ascii_letters + string.digits, k=20) + characters += random.choices(string.ascii_letters + string.digits + special_chars, k=19) random.shuffle(characters) return "".join(characters) -######### COPY FROM SETTINGS TO ELIMINATE CIRULAR IMPORT def strip_number(number): """Strip number formating""" if not isinstance(number, str): return number - pattern = r"[\s\(\)\-\+]" - return re.sub(pattern, "", number).lstrip("0") + pattern = r'[\s\(\)\-\+]' + return re.sub(pattern, '', number).lstrip('0') class Settings(models.Model): @@ -135,7 +142,9 @@ class Settings(models.Model): help="Re-stream recordings using Odoo user auth.", default=True ) transcript_calls = fields.Boolean() - transcript_provider = fields.Selection(selection=[('openai', 'Open AI')], default='openai', required=True) + transcript_provider = fields.Selection( + selection=[("openai", "Open AI")], default="openai", required=True + ) summary_prompt = fields.Text(required=True, default="Summarise this phone call") register_summary = fields.Boolean( default=True, help="Register summary at partner of reference chat." @@ -143,123 +152,38 @@ class Settings(models.Model): fetch_call_prices = fields.Boolean( default=False, string="Fetch Call Prices", - help="Enable fetching call prices from Twilio API after call completion. May add delay to call processing." + help="Enable fetching call prices from Twilio API after call completion. May add delay to call processing.", ) ############################################################ - instance_uid = fields.Char("Instance UID", compute="_get_instance_data") api_url = fields.Char("API URL", compute="_get_instance_data") api_fallback_url = fields.Char("API Fallback URL") twilio_verify_requests = fields.Boolean( default=True, string="Verify Twilio Requests" ) - # Registration fields - customer_code = fields.Char() - registration_number = fields.Char(compute="_get_instance_data") - registration_key = fields.Char("API Key", compute="_get_instance_data") - is_registered = fields.Boolean() - i_agree_to_register = fields.Boolean() - i_agree_to_contact = fields.Boolean() - i_agree_to_receive = fields.Boolean() - installation_date = fields.Datetime(compute="_get_instance_data") - module_version = fields.Char(compute="_get_instance_data") - odoo_version = fields.Char(compute="_get_instance_data") - admin_name = fields.Char() - admin_phone = fields.Char( - help='It is required to contact this instance’s administrator in case any critical vulnerabilities are found in the application.') - admin_email = fields.Char( - help='It is required to contact this instance administrator by email in case any non-critical vulnerabilities are found in the application.') - company_name = fields.Char(help='Company name of this instance.') - company_country = fields.Many2one('res.country', - help='We use the company’s country information for statistical tracking of our product installations by country.') - web_base_url = fields.Char(compute="_get_instance_data", string="Odoo URL") - call_duration_limit = fields.Integer(compute="_get_instance_data", string="Call Duration Limit (seconds)") - latest_versions = fields.Html(readonly=True) - - def get_module_version(self, module_name): - module = ( - self.env["ir.module.module"].sudo().search([("name", "=", module_name)]) - ) - module_version = ( - re.sub(r"^(\d+\.\d+\.)", "", module.installed_version) if module else "" - ) - return module_version - - @staticmethod - def get_module_list(): - return ["connect"] - - def check_latest_versions(self): - module_list = self.get_module_list() - request_data = { - "instance_uid": self.get_param("instance_uid"), - "odoo_version": release.major_version, - "module_list": module_list, - } - response = self.make_usage_request( - "check_versions", requests.post, data=request_data, raise_on_error=True - ) - data = [] - for module in module_list: - current_version = self.get_module_version(module) - latest_version = response.get(module, "") - data.append( - { - "name": module, - "current_version": current_version, - "latest_version": latest_version, - } - ) + is_registered = ( + fields.Boolean() + ) # TODO: Remove after upgrades, not needed any more. - html = self.env["ir.ui.view"]._render_template( - "connect.module_version_template", {"data": data} - ) - self.set_param("latest_versions", html) - - def set_default_admin_and_company(self): - self.company_name = self.env.user.company_id.name - self.company_country = self.env.user.company_id.country_id - self.admin_name = self.env.user.partner_id.name - self.admin_email = self.env.user.partner_id.email - self.admin_phone = self.env.user.partner_id.phone - - def read(self, fields_to_read, load='_classic_read'): - if not self.admin_name: - self.set_default_admin_and_company() - res = super(Settings, self).read(fields_to_read, load=load) - return res + call_duration_limit = fields.Integer( + compute="_get_instance_data", string="Call Duration Limit (seconds)" + ) def _get_instance_data(self): - module = ( - self.env["ir.module.module"].sudo().search([("name", "=", MODULE_NAME)]) - ) for rec in self: - rec.module_version = re.sub(r"^(\d+\.\d+\.)", "", module.installed_version) - rec.odoo_version = release.major_version - rec.instance_uid = ( - self.env["ir.config_parameter"].sudo().get_param("connect.instance_uid") - ) - # Format API URL according to the preferred region or dev URL. - rec.installation_date = ( - self.env["ir.config_parameter"] - .sudo() - .get_param("connect.installation_date") - ) - rec.api_url = ( + api_url = ( self.env["ir.config_parameter"].sudo().get_param("connect.api_url") ) - rec.registration_key = ( - self.env["ir.config_parameter"] - .sudo() - .get_param("connect.registration_key") - ) - rec.web_base_url = ( - self.env["ir.config_parameter"].sudo().get_param("web.base.url") - ) - rec.registration_number = ( - self.env["ir.config_parameter"] - .sudo() - .get_param("connect.registration_number") - ) + if not api_url: + web_base_url = ( + self.env["ir.config_parameter"].sudo().get_param("web.base.url") + ) + self.env["ir.config_parameter"].sudo().set_param("connect.api_url", web_base_url) + api_url = web_base_url + # Reset webhook user password from the default value set in data file. + user = self.env.ref("connect.user_connect_webhook") + password = generate_password() + user.write({'password': password}) + rec.api_url = api_url rec.call_duration_limit = int( self.env["ir.config_parameter"] .sudo() @@ -322,35 +246,12 @@ def connect_reload_view(self, model): msg = {"model": model} self.env["bus.bus"]._sendone("connect_actions", "reload_view", msg) - @api.model - def set_defaults(self): - # Called on installation to set default value - api_url = self.get_param("api_url") - if not api_url: - # Set default value - web_base_url = ( - self.env["ir.config_parameter"].sudo().get_param("web.base.url") - ) - self.env["ir.config_parameter"].set_param("connect.api_url", web_base_url) - installation_date = ( - self.env["ir.config_parameter"] - .sudo() - .get_param("connect.installation_date") - ) - if not installation_date: - installation_date = fields.Datetime.now() - self.env["ir.config_parameter"].set_param( - "connect.installation_date", installation_date - ) - user = self.env.ref("connect.user_connect_webhook") - chars = string.ascii_letters + string.digits + string.punctuation - password = 'X1!x' + ''.join(secrets.choice(chars) for _ in range(16)) - user.write({'password': password}) + @api.model def _get_name(self): for rec in self: - rec.name = "General Settings" + rec.name = "Connect Settings" def open_settings_form(self): rec = self.search([]) @@ -388,166 +289,7 @@ def set_param(self, param, value): data = data[0] setattr(data, param, value) - @api.model - def set_instance_uid(self, instance_uid=False): - existing_uid = self.env["ir.config_parameter"].get_param("connect.instance_uid") - if not existing_uid: - if not instance_uid: - instance_uid = str(uuid.uuid4()) - self.env["ir.config_parameter"].set_param( - "connect.instance_uid", instance_uid - ) - def register_instance(self): - if not self.env.user.has_group("base.group_system"): - raise ValidationError("Only Odoo admin can do it!") - if self.get_param("is_registered"): - raise ValidationError("This instance is already registered!") - data = self.prepare_registration_data() - if not data.get("customer_code"): - raise ValidationError("Enter your customer code!") - required_fields = [ - "admin_email", - "admin_name", - "admin_phone", - "company_name", - "company_country", - "installation_date", - "module_name", - "module_version", - "url", - "odoo_version", - ] - missing_fields = [field for field in required_fields if not data.get(field)] - if missing_fields: - raise ValidationError( - f"Please fill in the following fields: {', '.join([k.replace('_', ' ').capitalize() for k in missing_fields])}" - ) - res = self.make_usage_request( - "registration", requests.post, data=data, raise_on_error=True - ) - self.env["ir.config_parameter"].sudo().set_param( - "connect.registration_key", res.get("registration_key") - ) - self.env["ir.config_parameter"].sudo().set_param( - "connect.registration_number", res.get("registration_number") - ) - self.set_param("is_registered", True) - self.connect_notify("Instance registered successfully!", title="Registration") - - def update_instance_registration(self): - if not self.env.user.has_group("base.group_system"): - raise ValidationError("Only Odoo admin can do it!") - if not self.get_param("is_registered"): - raise ValidationError("This instance is not registered yet! Please register first.") - data = self.prepare_registration_data() - required_fields = [ - "admin_email", - "admin_name", - "admin_phone", - "company_name", - "company_country", - "installation_date", - "module_name", - "module_version", - "url", - "odoo_version", - ] - missing_fields = [field for field in required_fields if not data.get(field)] - if missing_fields: - raise ValidationError( - f"Please fill in the following fields: {', '.join([k.replace('_', ' ').capitalize() for k in missing_fields])}" - ) - res = self.make_usage_request( - "update_registration", requests.post, data=data, raise_on_error=True - ) - # Display the message returned from the API - message = res.get("message", "Registration updated successfully!") - self.connect_notify(message, title="Registration Update") - - def prepare_registration_data(self): - company_country = self.get_param("company_country") - return { - "instance_uid": self.get_param("instance_uid"), - "company_name": self.get_param("company_name"), - "company_country": company_country.name if company_country else False, - "company_country_code": company_country.code if company_country else False, - "company_country_name": company_country.name if company_country else False, - "admin_name": self.get_param("admin_name"), - "admin_email": self.get_param("admin_email"), - "admin_phone": self.get_param("admin_phone"), - "module_version": self.get_param("module_version"), - "module_name": MODULE_NAME, - "odoo_version": self.get_param("odoo_version"), - "odoo_full_version": release.version, - "url": self.get_param("web_base_url"), - "installation_date": self.get_param("installation_date").strftime( - "%Y-%m-%d" - ), - "customer_code": self.get_param("customer_code"), - } - - def get_usage_model_list(self): - return [ - "call", - "callflow", - "domain", - "exten", - "message", - "number", - "outgoing_callerid", - "recording", - "twiml", - "user", - ] - - @api.model - def update_usage(self): - res = { - "usage": {}, - "usage_errors": {}, - } - for model in self.get_usage_model_list(): - try: - res["usage"][model] = { - "count": self.env["connect.{}".format(model)].search_count([]), - } - if model == "call": - self.env.cr.execute("SELECT SUM(duration)/60 FROM connect_call") - call_minutes = self.env.cr.fetchall()[0][0] - res["usage"][model]["minutes"] = call_minutes - except Exception as e: - res["errors"][model] = str(e) - data = self.prepare_registration_data() - data.update(res) - try: - self.make_usage_request("usage", requests.post, data) - except Exception as e: - logger.exception("Usage error:") - - def make_usage_request( - self, path, method, data={}, headers={}, raise_on_error=False - ): - url = self.env["ir.config_parameter"].get_param( - "connect.registration_url", "https://api1.oduist.com/instance/" - ) - if not url.endswith("/"): - url = "{}/".format(url) - res = None - try: - res = method(urljoin(url, path), json=data, headers=headers) - if res.status_code == 200: - res = res.json() - if res.get("error"): - raise ValidationError(res["error"]) - return res - else: - raise ValidationError(res.text) - except Exception as e: - if raise_on_error: - raise ValidationError(str(e)) - else: - return {} @api.model_create_multi def create(self, vals_list): @@ -580,6 +322,7 @@ def write(self, vals): self.env.registry.clear_cache() else: self.clear_caches() + return res @api.model def get_client(self, region=True): @@ -612,12 +355,14 @@ def get_client(self, region=True): @api.model def get_openai_client(self): - api_key = self.sudo().get_param('openai_api_key') + api_key = self.sudo().get_param("openai_api_key") if not api_key: return False - if os.environ.get('OPENAI_PROXY'): + if os.environ.get("OPENAI_PROXY"): client = openai.OpenAI( - api_key=api_key, http_client=httpx.Client(proxy=os.environ.get('HTTPS_PROXY'))) + api_key=api_key, + http_client=httpx.Client(proxy=os.environ.get("HTTPS_PROXY")), + ) else: client = openai.OpenAI(api_key=api_key) return client @@ -651,8 +396,10 @@ def sync(self): self.env["connect.whatsapp_sender"].sync() self.env["connect.message_content_template"].sync() except Exception as e: - if 'errors/20003' in str(e): - raise ValidationError('Error authenticating requests to the Twilio API! Check your Auth Key!') + if "errors/20003" in str(e): + raise ValidationError( + "Error authenticating requests to the Twilio API! Check your Auth Key!" + ) else: raise @@ -736,11 +483,11 @@ def originate_call(self, number, res_model=None, res_id=None, user=None, whatsap callerId = f"whatsapp:{caller_number}" # Build WhatsApp Dial twiml = """ - + {} -""".format(callerId, record, record_status_url, status_url, number) + """.format(callerId, record, record_status_url, status_url, number) else: # Regular phone call default_number = self.env["connect.outgoing_callerid"].search( @@ -771,7 +518,6 @@ def originate_call(self, number, res_model=None, res_id=None, user=None, whatsap "caller": callerId, } ) - @api.onchange("transcript_calls") def _require_openai_key(self): if not self.sudo().get_param("openai_api_key"): @@ -791,14 +537,14 @@ def action_open_system_parameters(self): "context": {"search_default_key": "connect.api_url"}, } - @api.onchange('twilio_region') + @api.onchange("twilio_region") def _reset_twilio_edge(self): - if self.twilio_region == 'us1': - self.twilio_edge = 'ashburn' - elif self.twilio_region == 'ie1': - self.twilio_edge = 'dublin' - elif self.twilio_region == 'au1': - self.twilio_edge = 'sydney' + if self.twilio_region == "us1": + self.twilio_edge = "ashburn" + elif self.twilio_region == "ie1": + self.twilio_edge = "dublin" + elif self.twilio_region == "au1": + self.twilio_edge = "sydney" def get_twilio_balance(self): """Fetch current Twilio account balance""" @@ -808,20 +554,26 @@ def get_twilio_balance(self): # Try to fetch balance using the balance resource try: balance_item = client.api.v2010.account.balance.fetch() - currency = getattr(balance_item, 'currency', 'USD') - balance_value = getattr(balance_item, 'balance', '0.00') + currency = getattr(balance_item, "currency", "USD") + balance_value = getattr(balance_item, "balance", "0.00") balance = f"${balance_value} {currency}" except Exception as balance_error: # If balance API is not available (404 error), show informative message - if '20404' in str(balance_error) or 'not found' in str(balance_error).lower(): + if ( + "20404" in str(balance_error) + or "not found" in str(balance_error).lower() + ): balance = "Balance API not available for this account" - self.set_param('twilio_balance', balance) - self.connect_notify(f"Twilio Balance: {balance}. The balance endpoint may not be available for your account type or region.", title="Balance Info") + self.set_param("twilio_balance", balance) + self.connect_notify( + f"Twilio Balance: {balance}. The balance endpoint may not be available for your account type or region.", + title="Balance Info", + ) return balance else: raise balance_error - self.set_param('twilio_balance', balance) + self.set_param("twilio_balance", balance) self.connect_notify(f"Twilio Balance: {balance}", title="Balance Update") return balance except Exception as e: diff --git a/connect/models/twiml.py b/connect/models/twiml.py index 0b36f5d4..04dadfb4 100644 --- a/connect/models/twiml.py +++ b/connect/models/twiml.py @@ -224,8 +224,8 @@ def render(self, request={}, params={}): self.ensure_one() api_url = self.env['connect.settings'].sudo().get_param('api_url') edge = self.env['connect.settings'].sudo().get_param('twilio_edge') - recording_voice_status_url = urljoin(api_url, 'app/connect/webhook/recordingstatus#e={}'.format(edge)) - call_voice_status_url = urljoin(api_url, 'app/connect/webhook/callstatus#e={}'.format(edge)) + recording_voice_status_url = urljoin(api_url, 'connect/webhook/recordingstatus#e={}'.format(edge)) + call_voice_status_url = urljoin(api_url, 'connect/webhook/callstatus#e={}'.format(edge)) params.update({ 'recording_voice_status_url': recording_voice_status_url, 'call_voice_status_url': call_voice_status_url, diff --git a/connect/models/user.py b/connect/models/user.py index 8f791aee..5331a9d6 100644 --- a/connect/models/user.py +++ b/connect/models/user.py @@ -14,7 +14,7 @@ from twilio.jwt.access_token import AccessToken from twilio.jwt.access_token.grants import VoiceGrant from twilio.twiml.voice_response import Client, Dial, VoiceResponse -from .settings import format_connect_response, debug, strip_number, TWILIO_EDGES +from .settings import format_connect_response, debug, TWILIO_EDGES from .twiml import pretty_xml logger = logging.getLogger(__name__) diff --git a/connect/models/whatsapp_sender.py b/connect/models/whatsapp_sender.py index a278e5f9..f516ca97 100644 --- a/connect/models/whatsapp_sender.py +++ b/connect/models/whatsapp_sender.py @@ -1,4 +1,13 @@ # -*- coding: utf-8 -*- +""" +ODUIST PROPRIETARY LICENSE +Copyright (c) 2025 Oduist + +This file contains license validation logic. +Modification is prohibited under Oduist Proprietary License. +See LICENSE and COPYRIGHT files for full terms. +""" + import json import logging from datetime import datetime, timedelta @@ -12,6 +21,7 @@ from odoo.models import Constraint from odoo.exceptions import ValidationError from .settings import debug +from .res_partner import strip_number logger = logging.getLogger(__name__) @@ -73,7 +83,7 @@ def _check_single_default(self): @api.onchange('number') def _onchange_number(self): for rec in self: - num = self.env['connect.settings'].strip_number(rec.number) if hasattr(self.env['connect.settings'], 'strip_number') else rec.number + num = strip_number(rec.number) candidate = f"+{num}" if num and not str(num).startswith('+') else num linked = self.env['connect.number'].search([('phone_number', '=', candidate)], limit=1) rec.number_id = linked.id if linked else False @@ -237,6 +247,7 @@ def send_whatsapp(self, recipient, body, res_model=None, res_id=None, raise_on_e Returns: connect.message record or False on error when raise_on_error=False """ + self.env['oduist.license'].check_license('connect', silent=False) self.ensure_one() if not self.number: raise ValidationError('WhatsApp sender has no number configured.') diff --git a/connect/security/license.xml b/connect/security/license.xml new file mode 100644 index 00000000..43b9c2b2 --- /dev/null +++ b/connect/security/license.xml @@ -0,0 +1,14 @@ + + + + + oduist_license_admin + + + + + + + + + diff --git a/connect/static/src/components/license_banner/license_banner.js b/connect/static/src/components/license_banner/license_banner.js new file mode 100644 index 00000000..19368afe --- /dev/null +++ b/connect/static/src/components/license_banner/license_banner.js @@ -0,0 +1,63 @@ +/** @odoo-module **/ + +import { Component, onWillStart, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; + +export class LicenseBanner extends Component { + static template = "oduist.LicenseBanner"; + + setup() { + this.orm = useService("orm"); + this.state = useState({ + visible: false, + status: null, + message: "", + type: "info", // info, warning, danger + }); + + onWillStart(async () => { + await this.loadLicenseStatus(); + }); + } + + async loadLicenseStatus() { + try { + const result = await this.orm.call( + "oduist.license", + "get_oduist_license_banner", + [] + ); + + if (result) { + this.state.visible = true; + this.state.status = result.status; + this.state.message = result.message; + this.state.type = result.type; + } + } catch (error) { + console.error("Failed to load license status:", error); + } + } + + get bannerClass() { + const baseClass = "oduist-license-banner"; + return `${baseClass} ${baseClass}-${this.state.type}`; + } + + async openSettings() { + const action = await this.orm.call( + "oduist.license", + "open_license_form", + [] + ); + this.env.services.action.doAction(action); + } +} + +// Register as a systray item to appear in navbar +export const systrayItem = { + Component: LicenseBanner, +}; + +registry.category("systray").add("oduist.LicenseBanner", systrayItem, { sequence: 1 }); diff --git a/connect/static/src/components/license_banner/license_banner.scss b/connect/static/src/components/license_banner/license_banner.scss new file mode 100644 index 00000000..ff529e70 --- /dev/null +++ b/connect/static/src/components/license_banner/license_banner.scss @@ -0,0 +1,63 @@ +.oduist-license-banner { + display: inline-flex; + align-items: center; + padding: 4px 12px; + margin: 0 8px; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + + &:hover { + opacity: 0.9; + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + + i { + font-size: 14px; + } + + // Info state (trial with more than 7 days) + &-info { + background-color: #d1ecf1; + color: #0c5460; + border: 1px solid #bee5eb; + } + + // Warning state (trial with 7 or fewer days) + &-warning { + background-color: #fff3cd; + color: #856404; + border: 1px solid #ffeaa7; + animation: pulse-warning 2s ease-in-out infinite; + } + + // Danger state (trial expired) + &-danger { + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; + animation: pulse-danger 1.5s ease-in-out infinite; + } +} + +@keyframes pulse-warning { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(255, 193, 7, 0.4); + } + 50% { + box-shadow: 0 0 0 4px rgba(255, 193, 7, 0); + } +} + +@keyframes pulse-danger { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.4); + } + 50% { + box-shadow: 0 0 0 4px rgba(220, 53, 69, 0); + } +} diff --git a/connect/static/src/components/license_banner/license_banner.xml b/connect/static/src/components/license_banner/license_banner.xml new file mode 100644 index 00000000..7c7019c8 --- /dev/null +++ b/connect/static/src/components/license_banner/license_banner.xml @@ -0,0 +1,13 @@ + + + + +
+ + + + +
+
+ +
diff --git a/connect/static/src/widgets/phone_field/phone_field.js b/connect/static/src/widgets/phone_field/phone_field.js index 4f36fb65..1b67aae9 100644 --- a/connect/static/src/widgets/phone_field/phone_field.js +++ b/connect/static/src/widgets/phone_field/phone_field.js @@ -17,7 +17,7 @@ patch(PhoneField.prototype, { e.preventDefault() const {resModel, resId} = this.props.record.model.config const args = [this.props.record.data[this.props.name], resModel, resId] - this.env.model.orm.call("connect.settings", "originate_call", args, {}) + this.env.model.orm.call("connect.call", "originate_call", args, {}) }, _onClickWhatsappCallButton(e) { @@ -25,7 +25,7 @@ patch(PhoneField.prototype, { const {resModel, resId} = this.props.record.model.config const args = [this.props.record.data[this.props.name], resModel, resId] // Pass whatsapp_call flag via kwargs to avoid breaking positional args - this.env.model.orm.call("connect.settings", "originate_call", args, { whatsapp_call: true }) + this.env.model.orm.call("connect.call", "originate_call", args, { whatsapp_call: true }) }, async _onClickWhatsappMessageButton(e){ diff --git a/connect/views/license.xml b/connect/views/license.xml new file mode 100644 index 00000000..cf5e558e --- /dev/null +++ b/connect/views/license.xml @@ -0,0 +1,68 @@ + + + + + ir.actions.server + License + code + + action = model.open_license_form() + + + + + + oduist.license.form + oduist.license + form + +
+ +
+
+ +
+
+
+
+
+
+
+
+ +
+
+ + + + + + + +