diff --git a/otoolkit_auth/LICENSE b/otoolkit_auth/LICENSE
new file mode 100644
index 0000000..5f7a2f3
--- /dev/null
+++ b/otoolkit_auth/LICENSE
@@ -0,0 +1,11 @@
+Odoo 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 the authors, typically via Odoo Apps, or if you have received a written agreement from the authors of the Software (see the COPYRIGHT file).
+
+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 Odoo Proprietary License (For example: LGPL, MIT, or proprietary licenses similar to this one).
+
+It is forbidden to publish, distribute, sublicense, or sell copies of the Software or modified copies of the Software.
+
+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.
\ No newline at end of file
diff --git a/otoolkit_auth/README.rst b/otoolkit_auth/README.rst
new file mode 100644
index 0000000..04528de
--- /dev/null
+++ b/otoolkit_auth/README.rst
@@ -0,0 +1,54 @@
+==================
+Otoolkit Auth
+==================
+
+This module allows you to configure your Otoolkit account in odoo. You will then be able to use all the Otoolkit apps.
+
+
+Question and contact
+==========
+If you have any questions or recommendations about this module, please contact us at contact@otoolkit.app
+
+Installation
+============
+
+To install this module:
+
+1. Download and place the module in your Odoo addons directory.
+2. Access your Odoo server and navigate to the Apps menu.
+3. Enable debug mode, then refresh the list by selecting Update Apps List.
+4. Finally, click the Install button to complete the installation.
+
+Upgrade
+============
+
+To upgrade this module:
+
+1. Download the updated module and replace the existing one in your Odoo addons directory.
+2. Restart your Odoo server.
+3. Log in, go to the Apps menu, and click the Upgrade button for the module.
+
+
+Configuration
+=============
+
+To configure the module, follow these steps:
+
+1. Go to the Settings menu of your Odoo instance.
+2. Go to the Otoolkit section.
+3. Add your API Key from your Otoolkit account.
+
+How to Generate an API Key ?
+
+1. Visit https://otoolkit.app and create an account (if you don’t already have one).
+2. Once logged in, navigate to the Developers section in your Otoolkit dashboard.
+3. Generate a new API Key and copy it.
+4. Paste the copied key into the Otoolkit section in your Odoo settings.
+
+Credits
+=======
+
+Author & Maintainer
+-------------------
+
+This module is maintained by the Otoolkit team.
\ No newline at end of file
diff --git a/otoolkit_auth/__init__.py b/otoolkit_auth/__init__.py
new file mode 100644
index 0000000..0650744
--- /dev/null
+++ b/otoolkit_auth/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/otoolkit_auth/__manifest__.py b/otoolkit_auth/__manifest__.py
new file mode 100644
index 0000000..5ace7df
--- /dev/null
+++ b/otoolkit_auth/__manifest__.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+{
+ 'name': "O'Toolkit",
+ 'summary': "This app is the basic block of the Otoolkit integration. It allows you to connect your Otoolkit account to your Odoo instance.",
+ 'description': """""",
+ 'author': "O'Solutions Company",
+ 'website': "https://osolutions.app/products/o-toolkit",
+ 'category': 'Other',
+ 'version': '19.0.1.0.0',
+ 'depends': [],
+ "external_dependencies": {"python": ["requests"]},
+ 'data': [
+ # security
+ "security/otoolkit_security.xml",
+
+ # views
+ "views/menu_views.xml",
+ "views/res_config_settings_views.xml",
+
+ # data
+ "data/ir_config_parameter.xml"
+ ],
+ 'images': ['static/description/cover.jpeg'],
+ 'license': 'OPL-1',
+ 'installable': True,
+ 'application': True,
+}
diff --git a/otoolkit_auth/data/ir_config_parameter.xml b/otoolkit_auth/data/ir_config_parameter.xml
new file mode 100644
index 0000000..a08050b
--- /dev/null
+++ b/otoolkit_auth/data/ir_config_parameter.xml
@@ -0,0 +1,9 @@
+
+
+
+
+ otoolkit.api.endpoint
+ https://api.otoolkit.app
+
+
+
diff --git a/otoolkit_auth/doc/index.rst b/otoolkit_auth/doc/index.rst
new file mode 100644
index 0000000..04528de
--- /dev/null
+++ b/otoolkit_auth/doc/index.rst
@@ -0,0 +1,54 @@
+==================
+Otoolkit Auth
+==================
+
+This module allows you to configure your Otoolkit account in odoo. You will then be able to use all the Otoolkit apps.
+
+
+Question and contact
+==========
+If you have any questions or recommendations about this module, please contact us at contact@otoolkit.app
+
+Installation
+============
+
+To install this module:
+
+1. Download and place the module in your Odoo addons directory.
+2. Access your Odoo server and navigate to the Apps menu.
+3. Enable debug mode, then refresh the list by selecting Update Apps List.
+4. Finally, click the Install button to complete the installation.
+
+Upgrade
+============
+
+To upgrade this module:
+
+1. Download the updated module and replace the existing one in your Odoo addons directory.
+2. Restart your Odoo server.
+3. Log in, go to the Apps menu, and click the Upgrade button for the module.
+
+
+Configuration
+=============
+
+To configure the module, follow these steps:
+
+1. Go to the Settings menu of your Odoo instance.
+2. Go to the Otoolkit section.
+3. Add your API Key from your Otoolkit account.
+
+How to Generate an API Key ?
+
+1. Visit https://otoolkit.app and create an account (if you don’t already have one).
+2. Once logged in, navigate to the Developers section in your Otoolkit dashboard.
+3. Generate a new API Key and copy it.
+4. Paste the copied key into the Otoolkit section in your Odoo settings.
+
+Credits
+=======
+
+Author & Maintainer
+-------------------
+
+This module is maintained by the Otoolkit team.
\ No newline at end of file
diff --git a/otoolkit_auth/i18n/fr.po b/otoolkit_auth/i18n/fr.po
new file mode 100644
index 0000000..5a1ea6f
--- /dev/null
+++ b/otoolkit_auth/i18n/fr.po
@@ -0,0 +1,109 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * otoolkit_auth
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 17.0+e\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2025-01-16 10:59+0000\n"
+"PO-Revision-Date: 2025-01-16 10:59+0000\n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Plural-Forms: \n"
+
+#. module: otoolkit_auth
+#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
+msgid ""
+"\n"
+" Open Website"
+msgstr ""
+"\n"
+"Aller sur le site"
+
+#. module: otoolkit_auth
+#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
+msgid "API Authentication"
+msgstr "Authentification"
+
+#. module: otoolkit_auth
+#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
+msgid "API Key"
+msgstr "Cléf API"
+
+#. module: otoolkit_auth
+#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
+msgid "Available balance on the account"
+msgstr "Solde disponible sur votre compte"
+
+#. module: otoolkit_auth
+#: model:ir.model,name:otoolkit_auth.model_res_config_settings
+msgid "Config Settings"
+msgstr "Paramètres de configuration"
+
+#. module: otoolkit_auth
+#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
+msgid "Enter an API key of your Otoolkit account here"
+msgstr "Entrez une clé API de votre compte Otoolkit ici"
+
+#. module: otoolkit_auth
+#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
+msgid "Otoolkit"
+msgstr ""
+
+#. module: otoolkit_auth
+#: model:ir.model.fields,field_description:otoolkit_auth.field_res_config_settings__otoolkit_api_key
+msgid "Otoolkit API key"
+msgstr "Clés API Otoolkit"
+
+#. module: otoolkit_auth
+#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
+msgid "Otoolkit Account"
+msgstr "Compte Otoolkit"
+
+#. module: otoolkit_auth
+#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
+msgid "Otoolkit Tokens"
+msgstr "Crédits Otookit"
+
+#. module: otoolkit_auth
+#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
+msgid "Refresh token count"
+msgstr "Rafraîchir le nombre de crédits"
+
+#. module: otoolkit_auth
+#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
+msgid "The API key is invalid. Please update your settings."
+msgstr "La clé API n'est pas valide. Veuillez mettre à jour vos paramètres."
+
+#. module: otoolkit_auth
+#: model:ir.model.fields,help:otoolkit_auth.field_res_config_settings__otoolkit_api_key
+msgid "This API key allow you to communicate with the otoolkit api."
+msgstr "Cette clé API vous permet de communiquer avec l'API otoolkit."
+
+#. module: otoolkit_auth
+#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
+msgid ""
+"To generate an API key, you need to create an Otoolkit account. To do this, "
+"follow the link below."
+msgstr ""
+"Pour générer une clé API, vous devez créer un compte Otoolkit. Pour ce "
+"faire, suivez le lien ci-dessous."
+
+#. module: otoolkit_auth
+#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
+msgid "To use otoolkit services, you need a valid api key."
+msgstr "Pour utiliser les services otoolkit, vous avez besoin d'une clé api valide."
+
+#. module: otoolkit_auth
+#: model:ir.model.fields,field_description:otoolkit_auth.field_res_config_settings__otoolkit_api_token_count
+msgid "Token Count"
+msgstr "Nombre de crédits"
+
+#. module: otoolkit_auth
+#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
+msgid "tokens"
+msgstr "crédits"
diff --git a/otoolkit_auth/models/__init__.py b/otoolkit_auth/models/__init__.py
new file mode 100644
index 0000000..8c9ac1b
--- /dev/null
+++ b/otoolkit_auth/models/__init__.py
@@ -0,0 +1,2 @@
+from . import res_config_settings
+from . import schema_builder
diff --git a/otoolkit_auth/models/res_config_settings.py b/otoolkit_auth/models/res_config_settings.py
new file mode 100644
index 0000000..6335502
--- /dev/null
+++ b/otoolkit_auth/models/res_config_settings.py
@@ -0,0 +1,79 @@
+import logging
+import requests
+
+from odoo import models, fields, api
+
+_logger = logging.getLogger(__name__)
+
+# Sentinel values for token count status
+_NO_KEY = -1
+_ERROR = -2
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ otoolkit_api_key = fields.Char(
+ string="O'Toolkit API Key",
+ config_parameter='otoolkit_api_key',
+ help="API key from your O'Solutions portal (portal.osolutions.app).",
+ )
+
+ otoolkit_api_token_count = fields.Integer(
+ string='Token Balance',
+ compute='_compute_token_count',
+ config_parameter='otoolkit_api_token_count',
+ readonly=True,
+ default=_NO_KEY,
+ )
+
+ otoolkit_key_status = fields.Selection(
+ [('valid', 'Valid'), ('invalid', 'Invalid'), ('error', 'Connection Error'), ('none', 'No Key')],
+ string='Key Status',
+ compute='_compute_token_count',
+ )
+
+ def _get_token_balance(self, api_key):
+ """Fetch token balance from O'Toolkit API (which validates via FastAPI)."""
+ api_endpoint = self.env['ir.config_parameter'].sudo().get_param(
+ 'otoolkit.api.endpoint', 'https://api.otoolkit.app'
+ )
+ if not api_key:
+ return _NO_KEY, 'none'
+
+ headers = {
+ 'Odoo-Api-Key': api_key,
+ 'Content-Type': 'application/json',
+ }
+ try:
+ response = requests.get(
+ f"{api_endpoint.rstrip('/')}/api/get-token-count/",
+ headers=headers,
+ timeout=10,
+ )
+ except requests.exceptions.RequestException as e:
+ _logger.warning("O'Toolkit API connection error: %s", e)
+ return _ERROR, 'error'
+
+ if response.status_code == 200:
+ data = response.json()
+ return data.get('token_count', 0), 'valid'
+ if response.status_code == 401:
+ return 0, 'invalid'
+
+ _logger.warning("O'Toolkit API returned %s: %s", response.status_code, response.text[:200])
+ return _ERROR, 'error'
+
+ @api.depends('otoolkit_api_key')
+ def _compute_token_count(self):
+ for record in self:
+ api_key = record.otoolkit_api_key
+ count, status = record._get_token_balance(api_key)
+ record.otoolkit_api_token_count = count
+ record.otoolkit_key_status = status
+ self.env['ir.config_parameter'].sudo().set_param(
+ 'otoolkit_api_token_count', str(count)
+ )
+
+ def refresh_token_count(self):
+ self._compute_token_count()
diff --git a/otoolkit_auth/models/schema_builder.py b/otoolkit_auth/models/schema_builder.py
new file mode 100644
index 0000000..1a7c1d2
--- /dev/null
+++ b/otoolkit_auth/models/schema_builder.py
@@ -0,0 +1,242 @@
+import logging
+
+from odoo import models, api
+
+_logger = logging.getLogger(__name__)
+
+# Default business models, grouped by category.
+# Only included if the corresponding module is installed.
+_DEFAULT_BUSINESS_MODELS = {
+ 'sale': ['sale.order', 'sale.order.line'],
+ 'account': ['account.move', 'account.move.line', 'account.payment'],
+ 'product': ['product.template', 'product.product', 'product.category'],
+ 'base': ['res.partner'],
+ 'stock': ['stock.picking', 'stock.move', 'stock.quant'],
+ 'purchase': ['purchase.order', 'purchase.order.line'],
+ 'crm': ['crm.lead'],
+ 'project': ['project.project', 'project.task'],
+ 'helpdesk': ['helpdesk.ticket'],
+ 'hr': ['hr.employee', 'hr.leave'],
+}
+
+# Fields to always skip (technical/internal)
+_SKIP_FIELDS = {
+ '__last_update', 'write_date', 'write_uid', 'create_date', 'create_uid',
+ 'display_name', 'message_ids', 'message_follower_ids', 'message_partner_ids',
+ 'message_channel_ids', 'message_attachment_count', 'message_has_error',
+ 'message_has_error_counter', 'message_has_sms_error', 'message_is_follower',
+ 'message_main_attachment_id', 'message_needaction', 'message_needaction_counter',
+ 'message_unread', 'message_unread_counter', 'website_message_ids',
+ 'activity_ids', 'activity_state', 'activity_user_id', 'activity_type_id',
+ 'activity_date_deadline', 'activity_summary', 'activity_exception_decoration',
+ 'activity_exception_icon', 'activity_type_icon', 'activity_calendar_event_id',
+ 'rating_ids', 'rating_last_value', 'rating_last_feedback', 'rating_last_image',
+ 'rating_count', 'rating_avg', 'rating_percentage_satisfaction',
+ 'access_url', 'access_token', 'access_warning',
+}
+
+# Type abbreviations for compact format
+_TYPE_MAP = {
+ 'char': 'char',
+ 'text': 'text',
+ 'html': 'html',
+ 'integer': 'int',
+ 'float': 'float',
+ 'monetary': 'mon',
+ 'boolean': 'bool',
+ 'date': 'date',
+ 'datetime': 'dt',
+ 'binary': 'bin',
+ 'selection': 'sel',
+ 'many2one': 'm2o',
+ 'one2many': 'o2m',
+ 'many2many': 'm2m',
+ 'reference': 'ref',
+}
+
+
+class SchemaBuilder(models.AbstractModel):
+ _name = 'otk.schema.builder'
+ _description = "O'Toolkit Schema Builder"
+
+ @api.model
+ def get_business_models(self):
+ """Return list of business-relevant model names.
+
+ Only includes models whose parent module is installed.
+ Can be overridden via ir.config_parameter 'otoolkit.business_models'.
+ """
+ ICP = self.env['ir.config_parameter'].sudo()
+ custom = ICP.get_param('otoolkit.business_models', '')
+ if custom:
+ return [m.strip() for m in custom.split(',') if m.strip()]
+
+ result = []
+ IrModule = self.env['ir.module.module'].sudo()
+ for module_name, model_names in _DEFAULT_BUSINESS_MODELS.items():
+ if module_name == 'base':
+ result.extend(model_names)
+ continue
+ installed = IrModule.search([
+ ('name', '=', module_name),
+ ('state', '=', 'installed'),
+ ], limit=1)
+ if installed:
+ # Verify model actually exists in registry
+ for model_name in model_names:
+ if model_name in self.env:
+ result.append(model_name)
+ return result
+
+ @api.model
+ def check_model_access(self, model_name):
+ """Check if current user has read access to the given model."""
+ try:
+ self.env[model_name].check_access('read')
+ return True
+ except Exception:
+ return False
+
+ @api.model
+ def build_model_schema(self, model_name, depth=1):
+ """Build a schema dict for a model with field metadata.
+
+ Args:
+ model_name: Technical model name (e.g. 'sale.order')
+ depth: How deep to follow relational fields (0 = no relations)
+
+ Returns:
+ dict with model info, fields list, and nested relation schemas
+ """
+ if model_name not in self.env:
+ return None
+
+ Model = self.env[model_name]
+ model_desc = Model._description or model_name
+
+ fields_info = Model.fields_get(attributes=[
+ 'string', 'type', 'relation', 'selection', 'required',
+ 'readonly', 'store',
+ ])
+
+ schema_fields = []
+ related_schemas = {}
+
+ for fname, finfo in sorted(fields_info.items()):
+ if fname.startswith('_') or fname in _SKIP_FIELDS:
+ continue
+ if not finfo.get('store', True):
+ continue
+
+ field_data = {
+ 'name': fname,
+ 'type': finfo['type'],
+ 'label': finfo.get('string', fname),
+ }
+
+ if finfo['type'] == 'selection' and finfo.get('selection'):
+ field_data['selection'] = finfo['selection']
+
+ if finfo['type'] in ('many2one', 'one2many', 'many2many') and finfo.get('relation'):
+ field_data['relation'] = finfo['relation']
+ if depth > 0 and finfo['relation'] not in related_schemas:
+ related_schemas[finfo['relation']] = self.build_model_schema(
+ finfo['relation'], depth=depth - 1
+ )
+
+ if finfo.get('required'):
+ field_data['required'] = True
+
+ schema_fields.append(field_data)
+
+ return {
+ 'model': model_name,
+ 'description': model_desc,
+ 'fields': schema_fields,
+ 'related': related_schemas,
+ }
+
+ @api.model
+ def compress_schema(self, schema):
+ """Compress a schema dict into compact text format for API.
+
+ Saves ~75% tokens vs JSON.
+
+ Format:
+ === sale.order (Sales Order) ===
+ id:int name:char state:sel[draft,sent,sale] date_order:dt
+ partner_id->res.partner amount_total:mon
+ """
+ if not schema:
+ return ''
+
+ lines = []
+ lines.append(f"=== {schema['model']} ({schema['description']}) ===")
+
+ field_parts = []
+ for f in schema['fields']:
+ ftype = _TYPE_MAP.get(f['type'], f['type'])
+
+ if f['type'] == 'selection' and f.get('selection'):
+ # Include labels for AI to understand meaning (key=Label)
+ entries = [f"{s[0]}={s[1]}" for s in f['selection']]
+ part = f"{f['name']}:sel[{','.join(entries)}]"
+ elif f['type'] in ('many2one', 'one2many', 'many2many') and f.get('relation'):
+ arrow = '->' if f['type'] == 'many2one' else ':o2m->' if f['type'] == 'one2many' else ':m2m->'
+ if f['type'] == 'many2one':
+ part = f"{f['name']}->{f['relation']}"
+ else:
+ part = f"{f['name']}:{ftype}->{f['relation']}"
+ else:
+ part = f"{f['name']}:{ftype}"
+
+ field_parts.append(part)
+
+ # Group field parts into lines of ~120 chars
+ current_line = []
+ current_len = 0
+ for part in field_parts:
+ if current_len + len(part) + 1 > 120 and current_line:
+ lines.append(' '.join(current_line))
+ current_line = []
+ current_len = 0
+ current_line.append(part)
+ current_len += len(part) + 1
+ if current_line:
+ lines.append(' '.join(current_line))
+
+ # Add related model schemas (depth-1 only, no nested relations)
+ for rel_name, rel_schema in (schema.get('related') or {}).items():
+ if rel_schema:
+ lines.append('')
+ lines.append(self.compress_schema(rel_schema))
+
+ return '\n'.join(lines)
+
+ @api.model
+ def build_context_for_models(self, model_names=None, depth=1):
+ """Build compressed schema context for a list of models.
+
+ Args:
+ model_names: List of model names. If None, uses get_business_models().
+ depth: Relation traversal depth.
+
+ Returns:
+ Compressed schema string ready to send to API.
+ """
+ if model_names is None:
+ model_names = self.get_business_models()
+
+ parts = []
+ seen = set()
+ for model_name in model_names:
+ if model_name in seen:
+ continue
+ seen.add(model_name)
+ if not self.check_model_access(model_name):
+ continue
+ schema = self.build_model_schema(model_name, depth=depth)
+ if schema:
+ parts.append(self.compress_schema(schema))
+
+ return '\n\n'.join(parts)
diff --git a/otoolkit_auth/security/otoolkit_security.xml b/otoolkit_auth/security/otoolkit_security.xml
new file mode 100644
index 0000000..0a2ac40
--- /dev/null
+++ b/otoolkit_auth/security/otoolkit_security.xml
@@ -0,0 +1,7 @@
+
+
+
+ O'Toolkit
+ 90
+
+
diff --git a/otoolkit_auth/static/description/cover.jpeg b/otoolkit_auth/static/description/cover.jpeg
new file mode 100644
index 0000000..14f27b6
Binary files /dev/null and b/otoolkit_auth/static/description/cover.jpeg differ
diff --git a/otoolkit_auth/static/description/icon.png b/otoolkit_auth/static/description/icon.png
new file mode 100644
index 0000000..4008409
Binary files /dev/null and b/otoolkit_auth/static/description/icon.png differ
diff --git a/otoolkit_auth/static/description/index.html b/otoolkit_auth/static/description/index.html
new file mode 100644
index 0000000..17978ad
--- /dev/null
+++ b/otoolkit_auth/static/description/index.html
@@ -0,0 +1,13 @@
+
+
+
+