Seo
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import models
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<record id="otoolkit_api_endpoint" model="ir.config_parameter">
|
||||||
|
<field name="key">otoolkit.api.endpoint</field>
|
||||||
|
<field name="value">https://api.otoolkit.app</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
@@ -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.
|
||||||
@@ -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 ""
|
||||||
|
"<i class=\"o_button_icon oi oi-fw oi-arrow-right me-1\" aria-hidden=\"true\"/>\n"
|
||||||
|
" <span class=\"pl-2\">Open Website</span>"
|
||||||
|
msgstr ""
|
||||||
|
"<i class=\"o_button_icon oi oi-fw oi-arrow-right me-1\" aria-hidden=\"true\"/>\n"
|
||||||
|
"<span class=\"pl-2\">Aller sur le site</span>"
|
||||||
|
|
||||||
|
#. 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"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
from . import res_config_settings
|
||||||
|
from . import schema_builder
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="module_category_otoolkit" model="ir.module.category">
|
||||||
|
<field name="name">O'Toolkit</field>
|
||||||
|
<field name="sequence">90</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
|
After Width: | Height: | Size: 616 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<hr style="border: none; height: 1px; background: linear-gradient(to right, transparent, #ccc, transparent); margin: 20px auto;"/>
|
||||||
|
|
||||||
|
<section class="oe_container">
|
||||||
|
<div class="col-md-12 text-center" style="margin-top: 10px;">
|
||||||
|
<a target="_blank" href="https://otoolkit.app">
|
||||||
|
<h2>Otoolkit</h2>
|
||||||
|
</a>
|
||||||
|
<p>
|
||||||
|
<a href="https://otoolkit.app" target="_blank">Website: otoolkit.app</a><br>
|
||||||
|
Email: <a href="mailto:contact@otoolkit.app">contact@otoolkit.app</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<menuitem id="menu_otoolkit_root"
|
||||||
|
name="O'Toolkit"
|
||||||
|
web_icon="otoolkit_auth,static/description/icon.png"
|
||||||
|
sequence="90"/>
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<odoo>
|
||||||
|
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||||
|
<field name="name">res.config.settings.view.form.inherit.otoolkit_api</field>
|
||||||
|
<field name="model">res.config.settings</field>
|
||||||
|
<field name="priority" eval="40"/>
|
||||||
|
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//form" position="inside">
|
||||||
|
<app data-string="O'Toolkit" id="otoolkit_auth" string="O'Toolkit" name="otoolkit_auth">
|
||||||
|
<block title="API Authentication" id="api_authentication">
|
||||||
|
<setting id="otoolkit_api_key_settings" string="API Key"
|
||||||
|
help="Enter an O'Token key from your O'Solutions portal.">
|
||||||
|
<field name="otoolkit_api_key" class="o_light_label mt8"
|
||||||
|
placeholder="oso_k_..."/>
|
||||||
|
</setting>
|
||||||
|
<setting id="otoolkit_account_settings" string="O'Solutions Portal"
|
||||||
|
help="Manage your API keys, token balance and usage from the O'Solutions portal.">
|
||||||
|
<a href="https://portal.osolutions.app/dashboard/api-keys" target="_blank"
|
||||||
|
class="btn-link" role="button">
|
||||||
|
<i class="o_button_icon oi oi-fw oi-arrow-right me-1" aria-hidden="true"/>
|
||||||
|
<span>Open Portal</span>
|
||||||
|
</a>
|
||||||
|
</setting>
|
||||||
|
<setting id="otoolkit_tokens_settings" string="Token Balance"
|
||||||
|
help="Available O'Token balance on your account.">
|
||||||
|
<field name="otoolkit_key_status" invisible="1"/>
|
||||||
|
|
||||||
|
<div invisible="otoolkit_key_status != 'invalid'"
|
||||||
|
class="alert alert-danger mb-0" role="alert">
|
||||||
|
This API key is not valid. Please check your key in the
|
||||||
|
<a href="https://portal.osolutions.app/dashboard/api-keys"
|
||||||
|
target="_blank">O'Solutions Portal</a>.
|
||||||
|
</div>
|
||||||
|
<div invisible="otoolkit_key_status != 'error'"
|
||||||
|
class="alert alert-warning mb-0" role="alert">
|
||||||
|
Could not connect to the O'Toolkit API. Please try again later.
|
||||||
|
</div>
|
||||||
|
<div invisible="otoolkit_key_status != 'none'"
|
||||||
|
class="alert alert-info mb-0" role="alert">
|
||||||
|
Enter an API key to start using O'Toolkit services.
|
||||||
|
<a href="https://portal.osolutions.app/dashboard/api-keys"
|
||||||
|
target="_blank">Get your key</a>
|
||||||
|
</div>
|
||||||
|
<div invisible="otoolkit_key_status != 'valid'">
|
||||||
|
<span class="fw-bold fs-5"><field name="otoolkit_api_token_count" class="d-inline"/> tokens</span>
|
||||||
|
<button string="Refresh" type="object" name="refresh_token_count"
|
||||||
|
class="btn-link ms-2"/>
|
||||||
|
</div>
|
||||||
|
</setting>
|
||||||
|
</block>
|
||||||
|
</app>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
==================
|
||||||
|
Auto Translate Fields
|
||||||
|
==================
|
||||||
|
|
||||||
|
This module allows you to automatically translate all the fields that can be translated in Odoo.
|
||||||
|
|
||||||
|
It adds a button to the translation popup that will translate the content from the language activated for your user into the other languages of the instance.
|
||||||
|
|
||||||
|
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
|
||||||
|
=============
|
||||||
|
|
||||||
|
This module operates directly, with no configuration required.
|
||||||
|
|
||||||
|
Credits
|
||||||
|
=======
|
||||||
|
|
||||||
|
Author & Maintainer
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
This module is maintained by the Otoolkit team.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
from . import models
|
||||||
|
from . import wizards
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
{
|
||||||
|
'name': "Auto Translate Fields",
|
||||||
|
'summary': "Seamlessly translate any translatable fields in Odoo across all available languages with a single click.",
|
||||||
|
'description': """An O'Toolkit app that allow you to seamlessly translate any translatable fields in Odoo across all available languages with a single click.""",
|
||||||
|
'author': "O'Solutions Company",
|
||||||
|
'website': "https://osolutions.app/products/o-toolkit",
|
||||||
|
'category': 'Tools',
|
||||||
|
'version': '19.0.3.0.0',
|
||||||
|
'depends': ['web', 'otoolkit_auth'],
|
||||||
|
'data': [
|
||||||
|
"security/ir.model.access.csv",
|
||||||
|
|
||||||
|
"views/translation_views.xml",
|
||||||
|
"views/translation_config_views.xml",
|
||||||
|
|
||||||
|
"wizards/bulk_translate.xml",
|
||||||
|
|
||||||
|
"data/ir_cron.xml"
|
||||||
|
],
|
||||||
|
"external_dependencies": {"python": ["requests"]},
|
||||||
|
'assets': {
|
||||||
|
'web.assets_backend': [
|
||||||
|
'otoolkit_auto_translate_fields/static/src/js/translation_dialog_auto_translate.js',
|
||||||
|
'otoolkit_auto_translate_fields/static/src/xml/translation_dialog_auto_translate.xml',
|
||||||
|
'otoolkit_auto_translate_fields/static/src/js/json_pretty_field.js'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'images': ['static/description/cover.gif'],
|
||||||
|
'license': 'OPL-1',
|
||||||
|
'installable': True,
|
||||||
|
'application': True
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo noupdate="1">
|
||||||
|
<record id="otk_cron_process_bulk_translation" model="ir.cron">
|
||||||
|
<field name="name">Process Bulk Translation</field>
|
||||||
|
<field name="interval_number">5</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="active" eval="True" />
|
||||||
|
<field name="model_id" ref="otoolkit_auto_translate_fields.model_otk_translation_action"/>
|
||||||
|
<field name="code">model.cron_translate_action()</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
==================
|
||||||
|
Auto Translate Fields
|
||||||
|
==================
|
||||||
|
|
||||||
|
This module allows you to automatically translate all the fields that can be translated in Odoo.
|
||||||
|
|
||||||
|
It adds a button to the translation popup that will translate the content from the language activated for your user into the other languages of the instance.
|
||||||
|
|
||||||
|
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
|
||||||
|
=============
|
||||||
|
|
||||||
|
This module operates directly, with no configuration required.
|
||||||
|
|
||||||
|
Credits
|
||||||
|
=======
|
||||||
|
|
||||||
|
Author & Maintainer
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
This module is maintained by the Otoolkit team.
|
||||||
@@ -0,0 +1,833 @@
|
|||||||
|
# Translation of Odoo Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * otoolkit_auto_translate_fields
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: Odoo Server 19.0+e\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2026-06-10 18:10+0000\n"
|
||||||
|
"PO-Revision-Date: 2026-06-10 18:10+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_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation_config.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "%d models have been configured for bulk translation."
|
||||||
|
msgstr "%d modèles ont été configurés pour la traduction en masse."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/wizards/bulk_translate.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"%d records queued for translation.\n"
|
||||||
|
"%d records skipped (no translatable content in selected fields)."
|
||||||
|
msgstr ""
|
||||||
|
"%d enregistrements mis en file d'attente pour traduction.\n"
|
||||||
|
"%d enregistrements ignorés (aucun contenu traduisible dans les champs "
|
||||||
|
"sélectionnés)."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_action_form
|
||||||
|
msgid "<i class=\"fa fa-exclamation-triangle\" title=\"Skipped records\"/>"
|
||||||
|
msgstr "<i class=\"fa fa-exclamation-triangle\" title=\"Enregistrements ignorés\"/>"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_action_form
|
||||||
|
msgid "<i class=\"fa fa-spinner\" title=\"Translating\"/>"
|
||||||
|
msgstr "<i class=\"fa fa-spinner\" title=\"Traduction en cours\"/>"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otk_translation_config_form
|
||||||
|
msgid "<span>Translatable Fields</span>"
|
||||||
|
msgstr "<span>Champs traduisibles</span>"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otk_translation_config_form
|
||||||
|
msgid ""
|
||||||
|
"<strong>Warning:</strong> This model has no translatable fields. Bulk "
|
||||||
|
"translation will not be useful."
|
||||||
|
msgstr ""
|
||||||
|
"<strong>Attention :</strong> ce modèle n'a aucun champ traduisible. La "
|
||||||
|
"traduction en masse ne sera d'aucune utilité."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation_config.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "A configuration already exists for this model."
|
||||||
|
msgstr "Une configuration existe déjà pour ce modèle."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Access denied to the translation service"
|
||||||
|
msgstr "Accès refusé au service de traduction"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_config__active
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otk_translation_config_search
|
||||||
|
msgid "Active"
|
||||||
|
msgstr "Actif"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model_terms:ir.actions.act_window,help:otoolkit_auto_translate_fields.action_otk_translation_config
|
||||||
|
msgid ""
|
||||||
|
"Add models here to enable the \"Bulk translate\" action on their list views.\n"
|
||||||
|
" No code changes required - just select a model and it will automatically\n"
|
||||||
|
" get the bulk translation feature."
|
||||||
|
msgstr ""
|
||||||
|
"Ajoutez ici des modèles pour activer l'action « Traduire en masse » sur leurs vues liste.\n"
|
||||||
|
" Aucune modification de code requise : sélectionnez simplement un modèle et il\n"
|
||||||
|
" bénéficiera automatiquement de la traduction en masse."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-javascript
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/static/src/js/translation_dialog_auto_translate.js:0
|
||||||
|
#, python-format
|
||||||
|
msgid "All translations are already filled."
|
||||||
|
msgstr "Toutes les traductions sont déjà remplies."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"An error has occurred (HTTP %d). You can restart the translation from the "
|
||||||
|
"parent action."
|
||||||
|
msgstr ""
|
||||||
|
"Une erreur s'est produite (HTTP %d). Vous pouvez relancer la traduction "
|
||||||
|
"depuis l'action parente."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "An error occurred while contacting the translation service: %s"
|
||||||
|
msgstr ""
|
||||||
|
"Une erreur s'est produite lors du contact avec le service de traduction : %s"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields.selection,name:otoolkit_auto_translate_fields.selection__otk_translation__status__api_key_error
|
||||||
|
msgid "Api Key Error"
|
||||||
|
msgstr "Erreur de clé API"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otk_translation_config_form
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otk_translation_config_search
|
||||||
|
msgid "Archived"
|
||||||
|
msgstr "Archivé"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-javascript
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/static/src/xml/translation_dialog_auto_translate.xml:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Auto Translate"
|
||||||
|
msgstr "Tout traduire"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__base_language_id
|
||||||
|
msgid "Base language"
|
||||||
|
msgstr "Langue de base"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation_config.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Bulk Translation Enabled"
|
||||||
|
msgstr "Traduction en masse activée"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation_config.py:0
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation_config.py:0
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation_mixin.py:0
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation_mixin.py:0
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_bulk_translate_wizard_form
|
||||||
|
#, python-format
|
||||||
|
msgid "Bulk translate"
|
||||||
|
msgstr "Traduire en masse"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model,name:otoolkit_auto_translate_fields.model_otk_translation_bulk_translate
|
||||||
|
msgid "Bulk translate Wizard"
|
||||||
|
msgstr "Assistant de traduction en masse"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.ui.menu,name:otoolkit_auto_translate_fields.otoolkit_translation_actions_menu
|
||||||
|
msgid "Bulk translations"
|
||||||
|
msgstr "Traduction en masse"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_bulk_translate_wizard_form
|
||||||
|
msgid "Cancel"
|
||||||
|
msgstr "Annuler"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_action_form
|
||||||
|
msgid "Configuration"
|
||||||
|
msgstr "Configuration"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model_terms:ir.actions.act_window,help:otoolkit_auto_translate_fields.action_otk_translation_config
|
||||||
|
msgid "Configure models for bulk translation"
|
||||||
|
msgstr "Configurez les modèles pour la traduction en masse"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__create_uid
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__create_uid
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__create_uid
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_config__create_uid
|
||||||
|
msgid "Created by"
|
||||||
|
msgstr "Créé par"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__create_date
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__create_date
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__create_date
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_config__create_date
|
||||||
|
msgid "Created on"
|
||||||
|
msgstr "Créé le"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-javascript
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/static/src/xml/translation_dialog_auto_translate.xml:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Discard"
|
||||||
|
msgstr "Annuler"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__display_name
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__display_name
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_config__display_name
|
||||||
|
msgid "Display Name"
|
||||||
|
msgstr "Nom"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields.selection,name:otoolkit_auto_translate_fields.selection__otk_translation_action__status__done
|
||||||
|
msgid "Done"
|
||||||
|
msgstr "Terminé"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__done_record_ids
|
||||||
|
msgid "Done translations"
|
||||||
|
msgstr "Traductions terminées"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.actions.server,name:otoolkit_auto_translate_fields.action_enable_all_translatable_models
|
||||||
|
msgid "Enable All Translatable Models"
|
||||||
|
msgstr "Activer tous les modèles traduisibles"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields.selection,name:otoolkit_auto_translate_fields.selection__otk_translation__status__error
|
||||||
|
#: model:ir.model.fields.selection,name:otoolkit_auto_translate_fields.selection__otk_translation_action__status__error
|
||||||
|
msgid "Error"
|
||||||
|
msgstr "Erreur"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__error_record_ids
|
||||||
|
msgid "Error translations"
|
||||||
|
msgstr "Traductions en erreur"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__field_ids
|
||||||
|
msgid "Fields"
|
||||||
|
msgstr "Champs"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__field_ids
|
||||||
|
msgid "Fields to translate"
|
||||||
|
msgstr "Champs à traduire"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-javascript
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/static/src/js/translation_dialog_auto_translate.js:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Get tokens"
|
||||||
|
msgstr "Obtenir des crédits"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otk_translation_config_search
|
||||||
|
msgid "Group By"
|
||||||
|
msgstr "Regrouper par"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_config__has_translatable_fields
|
||||||
|
msgid "Has Translatable Fields"
|
||||||
|
msgstr "A des champs traduisibles"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__id
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__id
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__id
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_config__id
|
||||||
|
msgid "ID"
|
||||||
|
msgstr "ID"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__initial_values
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_form
|
||||||
|
msgid "Initial value"
|
||||||
|
msgstr "Valeur de base"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Invalid request data"
|
||||||
|
msgstr "Données de requête invalides"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__base_language_id
|
||||||
|
msgid "Language"
|
||||||
|
msgstr "Langue"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_action_form
|
||||||
|
msgid ""
|
||||||
|
"Large contents can take several minutes. Results are fetched automatically every few minutes —\n"
|
||||||
|
" last check:"
|
||||||
|
msgstr ""
|
||||||
|
"Les contenus volumineux peuvent prendre plusieurs minutes. Les résultats sont récupérés automatiquement à intervalles réguliers —\n"
|
||||||
|
" dernière vérification :"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_mixin__last_translation_date
|
||||||
|
msgid "Last Translation Date"
|
||||||
|
msgstr "Date de dernière traduction"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__write_uid
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__write_uid
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__write_uid
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_config__write_uid
|
||||||
|
msgid "Last Updated by"
|
||||||
|
msgstr "Dernière modification par"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__write_date
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__write_date
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__write_date
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_config__write_date
|
||||||
|
msgid "Last Updated on"
|
||||||
|
msgstr "Dernière modification le"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__last_server_check
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__last_server_check
|
||||||
|
msgid "Last server check"
|
||||||
|
msgstr "Dernière vérification du serveur"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,help:otoolkit_auto_translate_fields.field_otk_translation__last_server_check
|
||||||
|
msgid ""
|
||||||
|
"Last time the scheduled action polled the translation server for this task's"
|
||||||
|
" result. A recent timestamp with a 'Translating on server' status means the "
|
||||||
|
"translation is still running server-side — large contents can take several "
|
||||||
|
"minutes."
|
||||||
|
msgstr ""
|
||||||
|
"Dernière fois que l'action planifiée a interrogé le serveur de traduction "
|
||||||
|
"pour le résultat de cette tâche. Un horodatage récent avec le statut "
|
||||||
|
"« Traduction en cours sur le serveur » signifie que la traduction est "
|
||||||
|
"toujours en cours côté serveur — les contenus volumineux peuvent prendre "
|
||||||
|
"plusieurs minutes."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__action_id
|
||||||
|
msgid "Linked action"
|
||||||
|
msgstr "Action liée"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__model_id
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__model_id
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__model_id
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_config__model_id
|
||||||
|
msgid "Model"
|
||||||
|
msgstr "Modèle"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__display_name
|
||||||
|
msgid "Name"
|
||||||
|
msgstr "Nom"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-javascript
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/static/src/js/translation_dialog_auto_translate.js:0
|
||||||
|
#, python-format
|
||||||
|
msgid "No other language available to translate from."
|
||||||
|
msgstr "Aucune autre langue disponible comme langue source."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/wizards/bulk_translate.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "No records selected for translation."
|
||||||
|
msgstr "Aucun enregistrement sélectionné pour la traduction."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-javascript
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/static/src/js/translation_dialog_auto_translate.js:0
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/static/src/js/translation_dialog_auto_translate.js:0
|
||||||
|
#, python-format
|
||||||
|
msgid "No translation returned for this term."
|
||||||
|
msgstr "Aucune traduction renvoyée pour ce terme."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "No valid text fields found to translate."
|
||||||
|
msgstr "Aucun champ texte valide à traduire."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/wizards/bulk_translate.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"None of the %d selected records have translatable content in the selected fields (%s).\n"
|
||||||
|
"\n"
|
||||||
|
"Please check that the records have content in the base language (%s)."
|
||||||
|
msgstr ""
|
||||||
|
"Aucun des %d enregistrements sélectionnés n'a de contenu traduisible dans les champs sélectionnés (%s).\n"
|
||||||
|
"\n"
|
||||||
|
"Veuillez vérifier que les enregistrements ont du contenu dans la langue de base (%s)."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__processing_count
|
||||||
|
msgid "On server"
|
||||||
|
msgstr "Sur le serveur"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-javascript
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/static/src/js/translation_dialog_auto_translate.js:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Open Settings"
|
||||||
|
msgstr "Ouvrir les paramètres"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_form
|
||||||
|
msgid "Open record"
|
||||||
|
msgstr "Ouvrir l'enregistrement"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.ui.menu,name:otoolkit_auto_translate_fields.otoolkit_menu
|
||||||
|
msgid "Otoolkit"
|
||||||
|
msgstr "Otoolkit"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__overwrite_existing
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__overwrite_existing
|
||||||
|
msgid "Overwrite existing translations"
|
||||||
|
msgstr "Écraser les traductions existantes"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.ui.menu,name:otoolkit_auto_translate_fields.otoolkit_translation_menu
|
||||||
|
msgid "Past translations"
|
||||||
|
msgstr "Traductions"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields.selection,name:otoolkit_auto_translate_fields.selection__otk_translation_action__status__pending
|
||||||
|
msgid "Pending"
|
||||||
|
msgstr "En attente"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__pending_record_ids
|
||||||
|
msgid "Pending translations"
|
||||||
|
msgstr "Traductions en attente"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.actions.server,name:otoolkit_auto_translate_fields.otk_cron_process_bulk_translation_ir_actions_server
|
||||||
|
msgid "Process Bulk Translation"
|
||||||
|
msgstr "Gérer les traductions de masse"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields.selection,name:otoolkit_auto_translate_fields.selection__otk_translation_action__status__processing
|
||||||
|
msgid "Processing"
|
||||||
|
msgstr "En cours"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__processing_record_ids
|
||||||
|
msgid "Processing translations"
|
||||||
|
msgstr "Traductions en cours"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__progress
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_action_form
|
||||||
|
msgid "Progress"
|
||||||
|
msgstr "Progression"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,help:otoolkit_auto_translate_fields.field_otk_translation_action__overwrite_existing
|
||||||
|
#: model:ir.model.fields,help:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__overwrite_existing
|
||||||
|
msgid ""
|
||||||
|
"Re-translate every term, including terms that already have a translation in "
|
||||||
|
"the target languages."
|
||||||
|
msgstr ""
|
||||||
|
"Retraduire chaque terme, y compris ceux qui ont déjà une traduction dans "
|
||||||
|
"les langues cibles."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_action_form
|
||||||
|
msgid "Record IDs"
|
||||||
|
msgstr "ID des enregistrements"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__record_id
|
||||||
|
msgid "Record id"
|
||||||
|
msgstr "ID de l'enregistrement"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_action_form
|
||||||
|
msgid "Retry translation"
|
||||||
|
msgstr "Réessayer de traduire"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields.selection,name:otoolkit_auto_translate_fields.selection__otk_translation__status__revert
|
||||||
|
msgid "Revert"
|
||||||
|
msgstr "Rétablir"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_form
|
||||||
|
msgid "Revert translation"
|
||||||
|
msgstr "Rétablir à la valeur initiale"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-javascript
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/static/src/xml/translation_dialog_auto_translate.xml:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Save"
|
||||||
|
msgstr "Sauvegarder"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_config__server_action_id
|
||||||
|
msgid "Server Action"
|
||||||
|
msgstr "Action serveur"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__skipped_count
|
||||||
|
msgid "Skipped"
|
||||||
|
msgstr "Ignorés"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__skipped_record_ids
|
||||||
|
msgid "Skipped (no content)"
|
||||||
|
msgstr "Ignorés (aucun contenu)"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__status
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__status
|
||||||
|
msgid "Status"
|
||||||
|
msgstr "Statut"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields.selection,name:otoolkit_auto_translate_fields.selection__otk_translation__status__success
|
||||||
|
msgid "Success"
|
||||||
|
msgstr "Succès"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-javascript
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/static/src/js/translation_dialog_auto_translate.js:0
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/static/src/js/translation_dialog_auto_translate.js:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Successfully translated"
|
||||||
|
msgstr "Traduction réussie"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__target_language_ids
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__target_language_ids
|
||||||
|
msgid "Target languages"
|
||||||
|
msgstr "Langues cibles"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__task_id
|
||||||
|
msgid "Task ID"
|
||||||
|
msgstr "ID de tâche"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "The default language text cannot be empty."
|
||||||
|
msgstr "Le texte de la langue par défaut ne peut pas être vide."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"The translation service is not configured. Please contact your "
|
||||||
|
"administrator."
|
||||||
|
msgstr ""
|
||||||
|
"Le service de traduction n'est pas configuré. Veuillez contacter votre "
|
||||||
|
"administrateur."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"The translation service is taking too long to respond. Please try again."
|
||||||
|
msgstr ""
|
||||||
|
"Le service de traduction met trop de temps à répondre. Veuillez réessayer."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"The translation service is temporarily unavailable (Error %d). Please try "
|
||||||
|
"again later."
|
||||||
|
msgstr ""
|
||||||
|
"Le service de traduction est temporairement indisponible (erreur %d). "
|
||||||
|
"Veuillez réessayer plus tard."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "The translation service returned an invalid response."
|
||||||
|
msgstr "Le service de traduction a renvoyé une réponse invalide."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__cost
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__token_cost
|
||||||
|
msgid "Token used"
|
||||||
|
msgstr "Crédits utilisés"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Too many requests. The service is rate-limited."
|
||||||
|
msgstr "Trop de requêtes. Le service est limité en débit."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Too many translation requests. Please wait a moment and try again."
|
||||||
|
msgstr ""
|
||||||
|
"Trop de demandes de traduction. Veuillez patienter un instant et réessayer."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.actions.server,name:otoolkit_auto_translate_fields.bulk_translate_config_action_for_product_template
|
||||||
|
msgid "Traduire en masse"
|
||||||
|
msgstr "Traduire en masse"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation_config.py:0
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_config__translatable_field_ids
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otk_translation_config_form
|
||||||
|
#, python-format
|
||||||
|
msgid "Translatable Fields"
|
||||||
|
msgstr "Champs traduisibles"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_bulk_translate_wizard_form
|
||||||
|
msgid "Translate"
|
||||||
|
msgstr "Traduire"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields.selection,name:otoolkit_auto_translate_fields.selection__otk_translation__status__pending
|
||||||
|
msgid "Translating on server"
|
||||||
|
msgstr "Traduction en cours sur le serveur"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-javascript
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/static/src/xml/translation_dialog_auto_translate.xml:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Translating..."
|
||||||
|
msgstr "Traduction en cours..."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model,name:otoolkit_auto_translate_fields.model_otk_translation_action
|
||||||
|
msgid "Translation Action"
|
||||||
|
msgstr "Action de traduction"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.actions.act_window,name:otoolkit_auto_translate_fields.action_otk_translation_config
|
||||||
|
#: model:ir.model,name:otoolkit_auto_translate_fields.model_otk_translation_config
|
||||||
|
msgid "Translation Configuration"
|
||||||
|
msgstr "Configuration de traduction"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model,name:otoolkit_auto_translate_fields.model_translation_dialog
|
||||||
|
msgid "Translation Dialog"
|
||||||
|
msgstr "Dialogue de traduction"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields.selection,name:otoolkit_auto_translate_fields.selection__otk_translation__status__translation_error
|
||||||
|
msgid "Translation Error"
|
||||||
|
msgstr "Erreur de traduction"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model,name:otoolkit_auto_translate_fields.model_otk_translation_mixin
|
||||||
|
msgid "Translation Mixin"
|
||||||
|
msgstr "Mixin de traduction"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.ui.menu,name:otoolkit_auto_translate_fields.otoolkit_translation_config_menu
|
||||||
|
msgid "Translation Settings"
|
||||||
|
msgstr "Paramètres de traduction"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/wizards/bulk_translate.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Translation Started"
|
||||||
|
msgstr "Traduction lancée"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.actions.act_window,name:otoolkit_auto_translate_fields.action_otoolkit_translation
|
||||||
|
#: model:ir.actions.act_window,name:otoolkit_auto_translate_fields.action_otoolkit_translation_action
|
||||||
|
msgid "Translation actions"
|
||||||
|
msgstr "Actions de traduction"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__translation_count
|
||||||
|
msgid "Translation count"
|
||||||
|
msgstr "Nombre de traductions"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Translation error: %s"
|
||||||
|
msgstr "Erreur de traduction : %s"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model,name:otoolkit_auto_translate_fields.model_otk_translation
|
||||||
|
msgid "Translation object with the details of the translation, the cost..."
|
||||||
|
msgstr "Objet de traduction avec les détails de la traduction, le coût..."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Translation service endpoint not found"
|
||||||
|
msgstr "Point de terminaison du service de traduction introuvable"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Translation service internal error"
|
||||||
|
msgstr "Erreur interne du service de traduction"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Translation service is currently unavailable"
|
||||||
|
msgstr "Le service de traduction est actuellement indisponible"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Translation service request timed out"
|
||||||
|
msgstr "La requête au service de traduction a expiré"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Translation service temporarily unavailable (bad gateway)"
|
||||||
|
msgstr "Service de traduction temporairement indisponible (bad gateway)"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__translations
|
||||||
|
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__translation_ids
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_action_form
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_form
|
||||||
|
msgid "Translations"
|
||||||
|
msgstr "Traductions"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"Unable to connect to the translation service. Please check your internet "
|
||||||
|
"connection."
|
||||||
|
msgstr ""
|
||||||
|
"Impossible de se connecter au service de traduction. Veuillez vérifier "
|
||||||
|
"votre connexion Internet."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"Unexpected response from translation service (Error %d). Please try again."
|
||||||
|
msgstr ""
|
||||||
|
"Réponse inattendue du service de traduction (erreur %d). Veuillez "
|
||||||
|
"réessayer."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otk_translation_config_search
|
||||||
|
msgid "With Translatable Fields"
|
||||||
|
msgstr "Avec champs traduisibles"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"You do not have enough credits to translate. Please add credits to your "
|
||||||
|
"balance to use this function."
|
||||||
|
msgstr ""
|
||||||
|
"Vous n'avez pas assez de crédits pour traduire. Veuillez ajouter des crédits"
|
||||||
|
" à votre solde pour utiliser cette fonction."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"Your API key is invalid. This may be due to an input error, deletion or "
|
||||||
|
"deactivation of the key. Please check your Otoolkit settings."
|
||||||
|
msgstr ""
|
||||||
|
"Votre clé API n'est pas valide. Cela peut être dû à une erreur de saisie, à "
|
||||||
|
"la suppression ou à la désactivation de la clé. Veuillez vérifier les "
|
||||||
|
"paramètres Otoolkit."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/otoolkit_auto_translate_fields/models/translation.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"Your API key is invalid. Use a valid key and restart the translation from "
|
||||||
|
"the parent action."
|
||||||
|
msgstr ""
|
||||||
|
"Votre clé API n'est pas valide. Utilisez une clé valide et relancez la "
|
||||||
|
"traduction depuis l'action parente."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_action_form
|
||||||
|
msgid "record(s) being translated on the O'Toolkit server."
|
||||||
|
msgstr "enregistrement(s) en cours de traduction sur le serveur O'Toolkit."
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_action_form
|
||||||
|
msgid "records skipped"
|
||||||
|
msgstr "enregistrements ignorés"
|
||||||
|
|
||||||
|
#. module: otoolkit_auto_translate_fields
|
||||||
|
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_action_form
|
||||||
|
msgid ""
|
||||||
|
"— these records had no translatable content in the selected fields and were "
|
||||||
|
"not sent to the API (no tokens consumed)."
|
||||||
|
msgstr ""
|
||||||
|
"— ces enregistrements n'avaient aucun contenu traduisible dans les champs "
|
||||||
|
"sélectionnés et n'ont pas été envoyés à l'API (aucun crédit consommé)."
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from . import translation_mixin
|
||||||
|
from . import translation_dialog
|
||||||
|
from . import translation
|
||||||
|
from . import translation_config
|
||||||
@@ -0,0 +1,825 @@
|
|||||||
|
import json
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
from odoo import models, fields, api, _
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_null_bytes(value):
|
||||||
|
"""Remove NULL bytes that PostgreSQL cannot store in JSON/text fields.
|
||||||
|
|
||||||
|
PostgreSQL's jsonb type cannot handle \u0000 (NULL byte) characters.
|
||||||
|
These often appear in content copied from PDFs or due to encoding issues.
|
||||||
|
"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.replace('\x00', '').replace('\u0000', '')
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
return {k: _sanitize_null_bytes(v) for k, v in value.items()}
|
||||||
|
elif isinstance(value, list):
|
||||||
|
return [_sanitize_null_bytes(v) for v in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_translatable_value(value):
|
||||||
|
"""Check if a value is valid for translation.
|
||||||
|
|
||||||
|
Returns False for: None, False (bool), empty strings, whitespace-only strings.
|
||||||
|
Returns True for non-empty strings (including HTML content).
|
||||||
|
"""
|
||||||
|
if value is None or value is False:
|
||||||
|
return False
|
||||||
|
if isinstance(value, str):
|
||||||
|
return bool(value.strip())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class TranslationAction(models.Model):
|
||||||
|
_name = 'otk.translation.action'
|
||||||
|
_description = 'Translation Action'
|
||||||
|
_rec_name = 'display_name'
|
||||||
|
_order = 'id desc'
|
||||||
|
|
||||||
|
display_name = fields.Char(string="Name", compute='_compute_display_name', store=True, readonly=True)
|
||||||
|
|
||||||
|
model_id = fields.Many2one('ir.model', string="Model", ondelete='cascade', readonly=True)
|
||||||
|
base_language_id = fields.Many2one('res.lang', string="Language", readonly=True)
|
||||||
|
target_language_ids = fields.Many2many('res.lang', string="Target languages", required=True, ondelete='cascade',
|
||||||
|
readonly=True)
|
||||||
|
field_ids = fields.Many2many(
|
||||||
|
'ir.model.fields',
|
||||||
|
string="Fields to translate",
|
||||||
|
domain="[('translate', 'in', ['standard', 'html_translate', 'xml_translate']), ('model_id', '=', model_id)]",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
overwrite_existing = fields.Boolean(
|
||||||
|
string="Overwrite existing translations",
|
||||||
|
default=False,
|
||||||
|
readonly=True,
|
||||||
|
help="Re-translate every term, including terms that already have a "
|
||||||
|
"translation in the target languages.",
|
||||||
|
)
|
||||||
|
pending_record_ids = fields.Json(default=list, string="Pending translations", readonly=True)
|
||||||
|
processing_record_ids = fields.Json(default=list, string="Processing translations", readonly=True)
|
||||||
|
done_record_ids = fields.Json(default=list, string="Done translations", readonly=True)
|
||||||
|
error_record_ids = fields.Json(default=list, string="Error translations", readonly=True)
|
||||||
|
skipped_record_ids = fields.Json(default=list, string="Skipped (no content)", readonly=True)
|
||||||
|
skipped_count = fields.Integer(string="Skipped", compute='_compute_skipped_count', store=True)
|
||||||
|
processing_count = fields.Integer(string="On server", compute='_compute_processing_count')
|
||||||
|
last_server_check = fields.Datetime(
|
||||||
|
string="Last server check", compute='_compute_last_server_check',
|
||||||
|
)
|
||||||
|
progress = fields.Float(store=True, string="Progress", compute="_compute_progress", readonly=True)
|
||||||
|
translation_count = fields.Integer(readonly=True, string="Translation count")
|
||||||
|
token_cost = fields.Integer(default=0, string="Token used", readonly=True)
|
||||||
|
|
||||||
|
translation_ids = fields.One2many(
|
||||||
|
comodel_name='otk.translation',
|
||||||
|
inverse_name='action_id',
|
||||||
|
string='Translations',
|
||||||
|
readonly=True)
|
||||||
|
|
||||||
|
status = fields.Selection(
|
||||||
|
[('pending', 'Pending'), ('done', 'Done'), ('error', 'Error'), ('processing', 'Processing')],
|
||||||
|
compute='_compute_status',
|
||||||
|
string='Status',
|
||||||
|
store=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_error_message_for_status(self, status_code, response_text=""):
|
||||||
|
"""Get a user-friendly error message based on HTTP status code."""
|
||||||
|
error_messages = {
|
||||||
|
400: _("Invalid request data"),
|
||||||
|
401: _("Your API key is invalid. Use a valid key and restart the translation from the parent action."),
|
||||||
|
403: _("Access denied to the translation service"),
|
||||||
|
404: _("Translation service endpoint not found"),
|
||||||
|
429: _("Too many requests. The service is rate-limited."),
|
||||||
|
500: _("Translation service internal error"),
|
||||||
|
502: _("Translation service temporarily unavailable (bad gateway)"),
|
||||||
|
503: _("Translation service is currently unavailable"),
|
||||||
|
504: _("Translation service request timed out"),
|
||||||
|
}
|
||||||
|
return error_messages.get(status_code, _("An error has occurred (HTTP %d). You can restart the translation from the parent action.") % status_code)
|
||||||
|
|
||||||
|
def _create_error_translation(self, record_id, status_code, error_details=""):
|
||||||
|
"""Create a translation record with error status and detailed message."""
|
||||||
|
if status_code == 401:
|
||||||
|
status = 'api_key_error'
|
||||||
|
else:
|
||||||
|
status = 'translation_error'
|
||||||
|
|
||||||
|
error_message = self._get_error_message_for_status(status_code)
|
||||||
|
if error_details:
|
||||||
|
error_message = f"{error_message}\nDetails: {error_details}"
|
||||||
|
|
||||||
|
self.env["otk.translation"].create({
|
||||||
|
'action_id': self.id,
|
||||||
|
'cost': 0,
|
||||||
|
'record_id': record_id,
|
||||||
|
'status': status,
|
||||||
|
'translations': error_message,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _move_record_to_error(self, record_id):
|
||||||
|
"""Move a record from pending to error state."""
|
||||||
|
pending = self.pending_record_ids if self.pending_record_ids else []
|
||||||
|
error = self.error_record_ids if self.error_record_ids else []
|
||||||
|
if record_id in pending:
|
||||||
|
pending.remove(record_id)
|
||||||
|
if record_id not in error:
|
||||||
|
error.append(record_id)
|
||||||
|
self.write({
|
||||||
|
'pending_record_ids': pending,
|
||||||
|
'error_record_ids': error,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _mark_pending_translations_as_error(self, translations, error_message):
|
||||||
|
"""Mark multiple pending translations as error with a message."""
|
||||||
|
for translation in translations:
|
||||||
|
translation.write({
|
||||||
|
'status': 'translation_error',
|
||||||
|
'translations': error_message,
|
||||||
|
})
|
||||||
|
# Move record to error state in the action
|
||||||
|
action = translation.action_id
|
||||||
|
if action:
|
||||||
|
processing = action.processing_record_ids if action.processing_record_ids else []
|
||||||
|
error_ids = action.error_record_ids if action.error_record_ids else []
|
||||||
|
record_id = translation.record_id
|
||||||
|
if record_id in processing:
|
||||||
|
try:
|
||||||
|
processing.remove(record_id)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if record_id not in error_ids:
|
||||||
|
error_ids.append(record_id)
|
||||||
|
action.write({
|
||||||
|
'processing_record_ids': processing,
|
||||||
|
'error_record_ids': error_ids,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _request_terms_translation(self, terms, terms_meta, target_langs, base_lang_code, record_id):
|
||||||
|
"""Queue a terms-mode translation task on the OToolKit API.
|
||||||
|
|
||||||
|
:param dict terms: ``{field_name: [text, ...]}`` base-language texts
|
||||||
|
:param dict terms_meta: ``{field_name: [{"source": term_en, "langs": [...]}]}``
|
||||||
|
aligned with ``terms``; echoed back by the API so the cron can map
|
||||||
|
translations to Odoo's source terms at apply time
|
||||||
|
:return: the API task id, or False on error
|
||||||
|
"""
|
||||||
|
api_key = self.env['ir.config_parameter'].sudo().get_param('otoolkit_api_key')
|
||||||
|
api_endpoint = self.env['ir.config_parameter'].sudo().get_param('otoolkit.api.endpoint')
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
_logger.error("OToolKit API key not configured for bulk translation")
|
||||||
|
self._create_error_translation(record_id, 401, "API key not configured")
|
||||||
|
self._move_record_to_error(record_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not api_endpoint:
|
||||||
|
_logger.error("OToolKit API endpoint not configured for bulk translation")
|
||||||
|
self._create_error_translation(record_id, 500, "API endpoint not configured")
|
||||||
|
self._move_record_to_error(record_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
payload = json.dumps({
|
||||||
|
"terms": terms,
|
||||||
|
"terms_meta": terms_meta,
|
||||||
|
"target_langs": target_langs,
|
||||||
|
"base_language": base_lang_code,
|
||||||
|
"record_id": record_id,
|
||||||
|
"odoo_user_id": self.env.user.id
|
||||||
|
})
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Odoo-Api-Key': api_key,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
url = f"{api_endpoint}/api/auto-translate-fields/v2/translate-terms/"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.request("POST", url, headers=headers, data=payload, timeout=180)
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
_logger.warning("OToolKit API request timed out for record %d: %s", record_id, url)
|
||||||
|
self._create_error_translation(record_id, 504, "Request timed out")
|
||||||
|
self._move_record_to_error(record_id)
|
||||||
|
return False
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
_logger.error("OToolKit API connection error for record %d: %s - %s", record_id, url, str(e))
|
||||||
|
self._create_error_translation(record_id, 503, f"Connection error: {str(e)}")
|
||||||
|
self._move_record_to_error(record_id)
|
||||||
|
return False
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
_logger.error("OToolKit API request failed for record %d: %s - %s", record_id, url, str(e))
|
||||||
|
self._create_error_translation(record_id, 500, f"Request failed: {str(e)}")
|
||||||
|
self._move_record_to_error(record_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
# Try to extract error details from response
|
||||||
|
error_details = ""
|
||||||
|
try:
|
||||||
|
error_json = response.json()
|
||||||
|
error_details = error_json.get("error", "") or error_json.get("detail", "")
|
||||||
|
error_type = error_json.get("type", "")
|
||||||
|
if error_type:
|
||||||
|
error_details = f"[{error_type}] {error_details}"
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
error_details = response.text[:200] if response.text else ""
|
||||||
|
|
||||||
|
_logger.warning(
|
||||||
|
"OToolKit API returned status %d for record %d: %s",
|
||||||
|
response.status_code, record_id, error_details
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_error_translation(record_id, response.status_code, error_details)
|
||||||
|
self._move_record_to_error(record_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
return data["task_id"]
|
||||||
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
|
_logger.error("Failed to parse OToolKit API response for record %d: %s", record_id, str(e))
|
||||||
|
self._create_error_translation(record_id, 500, f"Invalid response format: {str(e)}")
|
||||||
|
self._move_record_to_error(record_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def cron_translate_action(self):
|
||||||
|
get_param = self.env['ir.config_parameter'].sudo().get_param
|
||||||
|
max_time = int(get_param('otoolkit.translate.cron_max_time', 60))
|
||||||
|
max_record = int(get_param('otoolkit.translate.cron_max_records', 10))
|
||||||
|
max_iter = 10
|
||||||
|
action = self.env['otk.translation.action'].sudo().search([('status', '=', 'pending')], order='id asc', limit=1)
|
||||||
|
start = int(time.time())
|
||||||
|
|
||||||
|
while action and max_iter > 0:
|
||||||
|
can_restart = self.translate_action(action, max_record)
|
||||||
|
if not can_restart:
|
||||||
|
break
|
||||||
|
action = self.env['otk.translation.action'].sudo().search([('status', '=', 'pending')], order='id asc',
|
||||||
|
limit=1)
|
||||||
|
if int(time.time()) - start > max_time:
|
||||||
|
break
|
||||||
|
|
||||||
|
max_iter -= 1
|
||||||
|
|
||||||
|
self.cron_retrieve_tasks()
|
||||||
|
|
||||||
|
def cron_retrieve_tasks(self):
|
||||||
|
pending_translations = self.env["otk.translation"].sudo().search(
|
||||||
|
[('status', '=', 'pending')], order='id asc', limit=100
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(pending_translations) == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
api_key = self.env['ir.config_parameter'].sudo().get_param('otoolkit_api_key')
|
||||||
|
api_endpoint = self.env['ir.config_parameter'].sudo().get_param('otoolkit.api.endpoint')
|
||||||
|
if not api_key:
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = json.dumps({
|
||||||
|
"task_ids": pending_translations.mapped('task_id'),
|
||||||
|
})
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Odoo-Api-Key': api_key,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
url = f"{api_endpoint}/api/tasks/"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.request("POST", url, headers=headers, data=payload, timeout=120)
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
_logger.error("OToolKit API timeout while retrieving tasks")
|
||||||
|
self._mark_pending_translations_as_error(pending_translations, "API timeout while retrieving translation status")
|
||||||
|
return
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
_logger.error("OToolKit API connection error while retrieving tasks: %s", str(e))
|
||||||
|
self._mark_pending_translations_as_error(pending_translations, f"Connection error: {str(e)}")
|
||||||
|
return
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
_logger.error("OToolKit API request failed while retrieving tasks: %s", str(e))
|
||||||
|
self._mark_pending_translations_as_error(pending_translations, f"Request failed: {str(e)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
error_msg = f"API returned status {response.status_code}"
|
||||||
|
try:
|
||||||
|
error_json = response.json()
|
||||||
|
error_detail = error_json.get("error", "") or error_json.get("detail", "")
|
||||||
|
if error_detail:
|
||||||
|
error_msg = f"{error_msg}: {error_detail}"
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
if response.text:
|
||||||
|
error_msg = f"{error_msg}: {response.text[:200]}"
|
||||||
|
_logger.error("OToolKit API error while retrieving tasks: %s", error_msg)
|
||||||
|
self._mark_pending_translations_as_error(pending_translations, error_msg)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
tasks = response.json()["tasks"]
|
||||||
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
|
_logger.error("Failed to parse OToolKit API response for tasks: %s", str(e))
|
||||||
|
self._mark_pending_translations_as_error(pending_translations, f"Invalid API response format: {str(e)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
task_map = {task["id"]: task for task in tasks}
|
||||||
|
|
||||||
|
# Every polled translation gets a check timestamp so the UI can show
|
||||||
|
# that a long-running server-side task is being watched, not stalled.
|
||||||
|
pending_translations.write({'last_server_check': fields.Datetime.now()})
|
||||||
|
|
||||||
|
action_update = {}
|
||||||
|
|
||||||
|
for translation in pending_translations:
|
||||||
|
action_id = translation.action_id.id
|
||||||
|
if action_id not in action_update:
|
||||||
|
action_update[action_id] = {
|
||||||
|
'error': [],
|
||||||
|
'completed': [],
|
||||||
|
'action': translation.action_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
related_task = task_map.get(translation.task_id)
|
||||||
|
|
||||||
|
# Skip if task not found in API response (still pending on server side)
|
||||||
|
if not related_task:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Safely extract record_id with error handling
|
||||||
|
try:
|
||||||
|
active_id = related_task['params']['record_id']
|
||||||
|
except (KeyError, TypeError) as e:
|
||||||
|
_logger.error("Invalid task structure for task %s: missing params.record_id - %s", translation.task_id, str(e))
|
||||||
|
translation.write({
|
||||||
|
'status': 'translation_error',
|
||||||
|
'translations': f"Invalid task response structure: {str(e)}",
|
||||||
|
})
|
||||||
|
action_update[action_id]['error'].append(translation.record_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if related_task["status"] == "completed":
|
||||||
|
try:
|
||||||
|
action_update[action_id]['completed'].append(active_id)
|
||||||
|
action = action_update[action_id]['action']
|
||||||
|
record = self.env[action.model_id.model].with_context(lang=action.base_language_id.code).browse(active_id)
|
||||||
|
|
||||||
|
if not record.exists():
|
||||||
|
translation.write({
|
||||||
|
'status': 'translation_error',
|
||||||
|
'translations': "Record was deleted before the translations could be applied",
|
||||||
|
})
|
||||||
|
action_update[action_id]['completed'].remove(active_id)
|
||||||
|
action_update[action_id]['error'].append(active_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
cost = related_task['result'].get('cost', 0)
|
||||||
|
|
||||||
|
applied, previous_values = self._apply_terms_translations(record, related_task)
|
||||||
|
|
||||||
|
translation.write({
|
||||||
|
'cost': cost,
|
||||||
|
'status': 'success',
|
||||||
|
'translations': applied,
|
||||||
|
'initial_values': previous_values,
|
||||||
|
})
|
||||||
|
# Update last_translation_date only if the field exists on the model
|
||||||
|
# (for backward compatibility with models using the mixin)
|
||||||
|
if 'last_translation_date' in self.env[action.model_id.model]._fields:
|
||||||
|
record.write({
|
||||||
|
"last_translation_date": datetime.datetime.now(),
|
||||||
|
})
|
||||||
|
except (KeyError, TypeError) as e:
|
||||||
|
_logger.error("Error processing completed task %s for record %d: %s", translation.task_id, active_id, str(e))
|
||||||
|
translation.write({
|
||||||
|
'status': 'translation_error',
|
||||||
|
'translations': f"Error processing translation result: {str(e)}",
|
||||||
|
})
|
||||||
|
# Move from completed to error
|
||||||
|
if active_id in action_update[action_id]['completed']:
|
||||||
|
action_update[action_id]['completed'].remove(active_id)
|
||||||
|
action_update[action_id]['error'].append(active_id)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error("Unexpected error processing task %s for record %d: %s", translation.task_id, active_id, str(e))
|
||||||
|
translation.write({
|
||||||
|
'status': 'translation_error',
|
||||||
|
'translations': f"Unexpected error: {str(e)}",
|
||||||
|
})
|
||||||
|
if active_id in action_update[action_id]['completed']:
|
||||||
|
action_update[action_id]['completed'].remove(active_id)
|
||||||
|
action_update[action_id]['error'].append(active_id)
|
||||||
|
|
||||||
|
elif related_task["status"] == "failed":
|
||||||
|
# Extract error message from the API response
|
||||||
|
error_message = "Translation failed"
|
||||||
|
try:
|
||||||
|
result = related_task.get('result', {})
|
||||||
|
if isinstance(result, dict):
|
||||||
|
error_message = result.get('error', '') or result.get('message', '') or result.get('detail', '')
|
||||||
|
if not error_message:
|
||||||
|
error_message = str(result) if result else "Translation failed (no details provided)"
|
||||||
|
except Exception:
|
||||||
|
error_message = "Translation failed (could not parse error details)"
|
||||||
|
|
||||||
|
_logger.warning("Translation task %s failed for record %d: %s", translation.task_id, active_id, error_message)
|
||||||
|
action_update[action_id]['error'].append(active_id)
|
||||||
|
translation.write({
|
||||||
|
'status': 'translation_error',
|
||||||
|
'translations': error_message,
|
||||||
|
})
|
||||||
|
|
||||||
|
for key, value in action_update.items():
|
||||||
|
action = value['action']
|
||||||
|
processing = action.processing_record_ids if action.processing_record_ids else []
|
||||||
|
error = action.error_record_ids if action.error_record_ids else []
|
||||||
|
done = action.done_record_ids if action.done_record_ids else []
|
||||||
|
|
||||||
|
for translation_id in value['completed']:
|
||||||
|
try:
|
||||||
|
processing.remove(translation_id)
|
||||||
|
except ValueError:
|
||||||
|
_logger.debug("Record %d not found in processing list", translation_id)
|
||||||
|
if translation_id not in done:
|
||||||
|
done.append(translation_id)
|
||||||
|
|
||||||
|
for translation_id in value['error']:
|
||||||
|
try:
|
||||||
|
processing.remove(translation_id)
|
||||||
|
except ValueError:
|
||||||
|
_logger.debug("Record %d not found in processing list", translation_id)
|
||||||
|
if translation_id not in error:
|
||||||
|
error.append(translation_id)
|
||||||
|
|
||||||
|
action.write({
|
||||||
|
'processing_record_ids': processing,
|
||||||
|
'error_record_ids': error,
|
||||||
|
'done_record_ids': done,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _extract_record_terms(self, record, field_names, base_lang_code, target_langs, overwrite=False):
|
||||||
|
"""Build the per-term translation payload for one record.
|
||||||
|
|
||||||
|
Term extraction goes through ``get_field_translations`` so it matches
|
||||||
|
Odoo's own translation model exactly: per-language values keep an
|
||||||
|
identical structure (only text terms differ), and a term whose target
|
||||||
|
value is empty or identical to the source is untranslated. Only those
|
||||||
|
terms are sent, each restricted to the languages that actually need it
|
||||||
|
— unless ``overwrite`` re-translates everything.
|
||||||
|
|
||||||
|
:return: (terms, terms_meta) where
|
||||||
|
terms: ``{field_name: [text, ...]}`` base-language texts to translate
|
||||||
|
terms_meta: ``{field_name: [{"source": term_en, "langs": [...]}]}``
|
||||||
|
aligned with ``terms``
|
||||||
|
"""
|
||||||
|
terms = {}
|
||||||
|
terms_meta = {}
|
||||||
|
langs = list(set(target_langs) | {base_lang_code})
|
||||||
|
|
||||||
|
for field_name in field_names:
|
||||||
|
field = record._fields.get(field_name)
|
||||||
|
if field is None or not field.translate:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
translations, _ctx = record.get_field_translations(field_name, langs=langs)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning(
|
||||||
|
"Could not extract translation terms for %s.%s (record %d): %s",
|
||||||
|
record._name, field_name, record.id, str(e),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
by_source = {}
|
||||||
|
for entry in translations:
|
||||||
|
by_source.setdefault(entry['source'], {})[entry['lang']] = entry['value']
|
||||||
|
|
||||||
|
field_terms = []
|
||||||
|
field_meta = []
|
||||||
|
for term_en, values in by_source.items():
|
||||||
|
if callable(field.translate):
|
||||||
|
# value == '' means untranslated (or identical to the
|
||||||
|
# en_US term — Odoo's copy-of-source default)
|
||||||
|
needed = [l for l in target_langs if overwrite or not values.get(l)]
|
||||||
|
else:
|
||||||
|
# model-level translation: one term == the whole field
|
||||||
|
# value; untranslated langs fall back to the en_US value
|
||||||
|
needed = [
|
||||||
|
l for l in target_langs
|
||||||
|
if overwrite or not values.get(l) or values.get(l) == term_en
|
||||||
|
]
|
||||||
|
text = values.get(base_lang_code) or term_en
|
||||||
|
if not needed or not _is_valid_translatable_value(text):
|
||||||
|
continue
|
||||||
|
field_terms.append(_sanitize_null_bytes(text))
|
||||||
|
field_meta.append({"source": term_en, "langs": needed})
|
||||||
|
|
||||||
|
if field_terms:
|
||||||
|
terms[field_name] = field_terms
|
||||||
|
terms_meta[field_name] = field_meta
|
||||||
|
|
||||||
|
return terms, terms_meta
|
||||||
|
|
||||||
|
def _apply_terms_translations(self, record, task):
|
||||||
|
"""Apply a completed terms-translation task through the ORM.
|
||||||
|
|
||||||
|
Writes via ``update_field_translations`` so cache invalidation,
|
||||||
|
sanitization and ``write_date`` behave like any user write, and the
|
||||||
|
per-language values keep a structure identical to the source. Terms
|
||||||
|
whose source disappeared from the record between send and apply (the
|
||||||
|
record was edited meanwhile) simply don't match and are skipped.
|
||||||
|
|
||||||
|
:return: (applied, previous_values) where
|
||||||
|
applied: ``{field: {lang: {source_term: new_term}}}``
|
||||||
|
previous_values: revert payload — ``{field: {lang: {new_term: old_term}}}``
|
||||||
|
for term-translated fields, ``{field: {lang: old_value}}`` for
|
||||||
|
model-translated fields
|
||||||
|
"""
|
||||||
|
terms_meta = task['params'].get('terms_meta') or {}
|
||||||
|
translations_by_field = task['result'].get('translations') or {}
|
||||||
|
target_langs = task['params']['target_langs']
|
||||||
|
|
||||||
|
applied = {}
|
||||||
|
previous_values = {}
|
||||||
|
|
||||||
|
for field_name, field_meta in terms_meta.items():
|
||||||
|
field = record._fields.get(field_name)
|
||||||
|
if field is None or not field.translate:
|
||||||
|
continue
|
||||||
|
translated_terms = translations_by_field.get(field_name) or []
|
||||||
|
if len(translated_terms) != len(field_meta):
|
||||||
|
_logger.warning(
|
||||||
|
"Translation count mismatch for %s.%s (record %d): sent %d terms, got %d",
|
||||||
|
record._name, field_name, record.id, len(field_meta), len(translated_terms),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Map each en_US source term to its current value per language:
|
||||||
|
# update_field_translations matches old terms against the value as
|
||||||
|
# it currently stands in the target language, not the en_US source.
|
||||||
|
current, _ctx = record.get_field_translations(field_name, langs=target_langs)
|
||||||
|
current_map = {(e['lang'], e['source']): e['value'] for e in current}
|
||||||
|
|
||||||
|
payload = {}
|
||||||
|
revert = {}
|
||||||
|
field_applied = {}
|
||||||
|
for meta, term_translations in zip(field_meta, translated_terms):
|
||||||
|
source = meta['source']
|
||||||
|
for lang in meta['langs']:
|
||||||
|
new_term = term_translations.get(lang)
|
||||||
|
if not new_term or not isinstance(new_term, str):
|
||||||
|
_logger.warning(
|
||||||
|
"Missing translation for field %s, lang %s in record %d",
|
||||||
|
field_name, lang, record.id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
new_term = _sanitize_null_bytes(new_term)
|
||||||
|
if callable(field.translate):
|
||||||
|
old_term = current_map.get((lang, source)) or source
|
||||||
|
if old_term == new_term:
|
||||||
|
continue
|
||||||
|
payload.setdefault(lang, {})[old_term] = new_term
|
||||||
|
revert.setdefault(lang, {})[new_term] = old_term
|
||||||
|
else:
|
||||||
|
old_value = current_map.get((lang, source)) or source
|
||||||
|
if old_value == new_term:
|
||||||
|
continue
|
||||||
|
payload[lang] = new_term
|
||||||
|
revert[lang] = old_value
|
||||||
|
field_applied.setdefault(lang, {})[source] = new_term
|
||||||
|
|
||||||
|
if payload:
|
||||||
|
record.update_field_translations(field_name, payload)
|
||||||
|
applied[field_name] = field_applied
|
||||||
|
previous_values[field_name] = revert
|
||||||
|
|
||||||
|
return applied, previous_values
|
||||||
|
|
||||||
|
def translate_action(self, action, max_record):
|
||||||
|
active_ids = action.pending_record_ids[0:max_record]
|
||||||
|
langs = action.target_language_ids
|
||||||
|
target_langs = []
|
||||||
|
for lang in langs:
|
||||||
|
if lang.code != action.base_language_id.code:
|
||||||
|
target_langs.append(lang.code)
|
||||||
|
|
||||||
|
error_count = 0
|
||||||
|
translations_to_create = []
|
||||||
|
model_name = action.model_id.model
|
||||||
|
base_lang_code = action.base_language_id.code
|
||||||
|
field_names = action.field_ids.mapped('name')
|
||||||
|
|
||||||
|
for active_id in active_ids:
|
||||||
|
record = self.env[model_name].browse(active_id)
|
||||||
|
terms = {}
|
||||||
|
terms_meta = {}
|
||||||
|
if record.exists():
|
||||||
|
terms, terms_meta = self._extract_record_terms(
|
||||||
|
record, field_names, base_lang_code, target_langs,
|
||||||
|
overwrite=action.overwrite_existing,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Skip records with nothing to translate (no content, or every
|
||||||
|
# term already translated in every target language)
|
||||||
|
if not terms:
|
||||||
|
_logger.info(
|
||||||
|
"Skipping record %d (model=%s, lang=%s): no terms left to translate.",
|
||||||
|
active_id, model_name, base_lang_code,
|
||||||
|
)
|
||||||
|
pending = action.pending_record_ids if action.pending_record_ids else []
|
||||||
|
skipped = action.skipped_record_ids if action.skipped_record_ids else []
|
||||||
|
if active_id in pending:
|
||||||
|
pending.remove(active_id)
|
||||||
|
if active_id not in skipped:
|
||||||
|
skipped.append(active_id)
|
||||||
|
action.write({
|
||||||
|
'pending_record_ids': pending,
|
||||||
|
'skipped_record_ids': skipped,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
task_id = action._request_terms_translation(
|
||||||
|
terms, terms_meta, target_langs, base_lang_code, active_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not task_id:
|
||||||
|
error_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
translations_to_create.append({
|
||||||
|
'action_id': action.id,
|
||||||
|
'cost': 0,
|
||||||
|
'record_id': active_id,
|
||||||
|
'status': 'pending',
|
||||||
|
'translations': {},
|
||||||
|
'initial_values': {},
|
||||||
|
'task_id': task_id
|
||||||
|
})
|
||||||
|
|
||||||
|
pending = action.pending_record_ids if action.pending_record_ids else []
|
||||||
|
processing = action.processing_record_ids if action.processing_record_ids else []
|
||||||
|
pending.remove(active_id)
|
||||||
|
if active_id not in processing:
|
||||||
|
processing.append(active_id)
|
||||||
|
action.write({
|
||||||
|
'pending_record_ids': pending,
|
||||||
|
'processing_record_ids': processing,
|
||||||
|
})
|
||||||
|
|
||||||
|
if translations_to_create:
|
||||||
|
self.env["otk.translation"].create(translations_to_create)
|
||||||
|
|
||||||
|
return error_count != len(active_ids)
|
||||||
|
|
||||||
|
def retry_error_translation(self):
|
||||||
|
for record in self:
|
||||||
|
pending = record.pending_record_ids if record.pending_record_ids else []
|
||||||
|
error = record.error_record_ids if record.error_record_ids else []
|
||||||
|
pending.extend(error)
|
||||||
|
self.write({
|
||||||
|
'pending_record_ids': pending,
|
||||||
|
'error_record_ids': [],
|
||||||
|
})
|
||||||
|
|
||||||
|
@api.depends('pending_record_ids', 'done_record_ids')
|
||||||
|
def _compute_progress(self):
|
||||||
|
for record in self:
|
||||||
|
if not record.done_record_ids:
|
||||||
|
record.progress = 0.0
|
||||||
|
elif not record.pending_record_ids and not record.processing_record_ids:
|
||||||
|
record.progress = 100.0
|
||||||
|
else:
|
||||||
|
record.progress = len(record.done_record_ids) / record.translation_count * 100.0
|
||||||
|
|
||||||
|
@api.depends('skipped_record_ids')
|
||||||
|
def _compute_skipped_count(self):
|
||||||
|
for record in self:
|
||||||
|
record.skipped_count = len(record.skipped_record_ids) if record.skipped_record_ids else 0
|
||||||
|
|
||||||
|
@api.depends('processing_record_ids')
|
||||||
|
def _compute_processing_count(self):
|
||||||
|
for record in self:
|
||||||
|
record.processing_count = len(record.processing_record_ids) if record.processing_record_ids else 0
|
||||||
|
|
||||||
|
@api.depends('translation_ids.last_server_check')
|
||||||
|
def _compute_last_server_check(self):
|
||||||
|
for record in self:
|
||||||
|
checks = record.translation_ids.mapped('last_server_check')
|
||||||
|
record.last_server_check = max(filter(None, checks), default=False)
|
||||||
|
|
||||||
|
@api.depends('progress', 'error_record_ids')
|
||||||
|
def _compute_status(self):
|
||||||
|
for record in self:
|
||||||
|
if record.error_record_ids and len(record.error_record_ids) > 0 and (
|
||||||
|
not record.pending_record_ids or len(record.pending_record_ids) == 0) and (
|
||||||
|
not record.processing_record_ids or len(record.processing_record_ids) == 0):
|
||||||
|
record.status = 'error'
|
||||||
|
else:
|
||||||
|
record.status = 'done' if record.progress >= 100 else 'pending' if record.pending_record_ids and len(
|
||||||
|
record.pending_record_ids) > 0 else 'processing'
|
||||||
|
|
||||||
|
@api.depends('model_id')
|
||||||
|
def _compute_display_name(self):
|
||||||
|
for record in self:
|
||||||
|
record.display_name = "Action #" + str(record.id) + " - " + record.model_id.name
|
||||||
|
|
||||||
|
|
||||||
|
class Translation(models.Model):
|
||||||
|
_name = 'otk.translation'
|
||||||
|
_description = 'Translation object with the details of the translation, the cost...'
|
||||||
|
_order = 'id desc'
|
||||||
|
|
||||||
|
action_id = fields.Many2one('otk.translation.action', string="Linked action", ondelete='cascade', readonly=True)
|
||||||
|
record_id = fields.Integer(string='Record id', readonly=True)
|
||||||
|
model_id = fields.Many2one('ir.model', string='Model', readonly=True, related='action_id.model_id')
|
||||||
|
|
||||||
|
cost = fields.Float(string="Token used", readonly=True)
|
||||||
|
status = fields.Selection(
|
||||||
|
[
|
||||||
|
('error', "Error"),
|
||||||
|
('translation_error', "Translation Error"),
|
||||||
|
('api_key_error', "Api Key Error"),
|
||||||
|
('success', "Success"),
|
||||||
|
('revert', "Revert"),
|
||||||
|
('pending', "Translating on server"),
|
||||||
|
],
|
||||||
|
string="Status", readonly=True
|
||||||
|
)
|
||||||
|
last_server_check = fields.Datetime(
|
||||||
|
string="Last server check",
|
||||||
|
readonly=True,
|
||||||
|
help="Last time the scheduled action polled the translation server "
|
||||||
|
"for this task's result. A recent timestamp with a 'Translating "
|
||||||
|
"on server' status means the translation is still running "
|
||||||
|
"server-side — large contents can take several minutes.",
|
||||||
|
)
|
||||||
|
translations = fields.Json(string='Translations', readonly=True)
|
||||||
|
initial_values = fields.Json(string='Initial value', readonly=True)
|
||||||
|
|
||||||
|
task_id = fields.Integer(string="Task ID", readonly=True)
|
||||||
|
|
||||||
|
def revert_translation(self):
|
||||||
|
for record in self:
|
||||||
|
target = self.env[record.model_id.model].browse(record.record_id)
|
||||||
|
if not target.exists():
|
||||||
|
record.status = 'revert'
|
||||||
|
continue
|
||||||
|
|
||||||
|
for field_name, by_lang in (record.initial_values or {}).items():
|
||||||
|
field = target._fields.get(field_name)
|
||||||
|
if field is None or not field.translate or not by_lang:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if callable(field.translate) and any(
|
||||||
|
not isinstance(value, dict) for value in by_lang.values()
|
||||||
|
):
|
||||||
|
# Records translated before the terms-mode upgrade stored a
|
||||||
|
# whole-jsonb snapshot per language; only a raw column
|
||||||
|
# restore can reapply those faithfully.
|
||||||
|
record._revert_legacy_field(field_name, by_lang)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Term-level revert: {lang: {translated_term: original_term}}
|
||||||
|
# for term-translated fields, {lang: original_value} for
|
||||||
|
# model-translated fields — both are exactly the payload
|
||||||
|
# update_field_translations expects.
|
||||||
|
target.update_field_translations(field_name, by_lang)
|
||||||
|
|
||||||
|
record.status = 'revert'
|
||||||
|
|
||||||
|
def _revert_legacy_field(self, field_name, lang_values):
|
||||||
|
"""Restore a pre-terms-mode whole-jsonb snapshot of one field."""
|
||||||
|
self.ensure_one()
|
||||||
|
table = self.env[self.model_id.model]._table
|
||||||
|
query = f"""
|
||||||
|
SELECT {field_name}
|
||||||
|
FROM {table}
|
||||||
|
WHERE id = %s
|
||||||
|
"""
|
||||||
|
self.env.cr.execute(query, (self.record_id,))
|
||||||
|
result = self.env.cr.fetchone()
|
||||||
|
translate = _sanitize_null_bytes(result[0]) if result and result[0] else {}
|
||||||
|
|
||||||
|
for lang, initial_value in lang_values.items():
|
||||||
|
translate[lang] = initial_value or False
|
||||||
|
|
||||||
|
update_query = f"""
|
||||||
|
UPDATE {table}
|
||||||
|
SET {field_name} = %s
|
||||||
|
WHERE id = %s
|
||||||
|
"""
|
||||||
|
self.env.cr.execute(update_query, (json.dumps(_sanitize_null_bytes(translate)), self.record_id))
|
||||||
|
self.env[self.model_id.model].browse(self.record_id).invalidate_recordset([field_name])
|
||||||
|
|
||||||
|
def open_record(self):
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': 'Linked record',
|
||||||
|
'res_model': self.model_id.model,
|
||||||
|
'res_id': self.record_id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'current', # or 'new' to open in a popup
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
from odoo import models, fields, api, _
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
class TranslationConfig(models.Model):
|
||||||
|
_name = 'otk.translation.config'
|
||||||
|
_description = 'Translation Configuration'
|
||||||
|
_rec_name = 'model_id'
|
||||||
|
|
||||||
|
model_id = fields.Many2one(
|
||||||
|
'ir.model',
|
||||||
|
string="Model",
|
||||||
|
required=True,
|
||||||
|
ondelete='cascade',
|
||||||
|
domain="[('transient', '=', False)]",
|
||||||
|
)
|
||||||
|
active = fields.Boolean(default=True)
|
||||||
|
has_translatable_fields = fields.Boolean(
|
||||||
|
compute='_compute_has_translatable_fields',
|
||||||
|
store=True,
|
||||||
|
)
|
||||||
|
translatable_field_ids = fields.Many2many(
|
||||||
|
'ir.model.fields',
|
||||||
|
string="Translatable Fields",
|
||||||
|
compute='_compute_has_translatable_fields',
|
||||||
|
store=True,
|
||||||
|
)
|
||||||
|
server_action_id = fields.Many2one(
|
||||||
|
'ir.actions.server',
|
||||||
|
string="Server Action",
|
||||||
|
readonly=True,
|
||||||
|
ondelete='set null',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.constrains('model_id')
|
||||||
|
def _check_unique_model_id(self):
|
||||||
|
for record in self:
|
||||||
|
if record.model_id:
|
||||||
|
if self.search([('model_id', '=', record.model_id.id), ('id', '!=', record.id)]):
|
||||||
|
raise ValidationError(_("A configuration already exists for this model."))
|
||||||
|
|
||||||
|
@api.depends('model_id')
|
||||||
|
def _compute_has_translatable_fields(self):
|
||||||
|
for record in self:
|
||||||
|
if record.model_id:
|
||||||
|
translatable_fields = self.env['ir.model.fields'].search([
|
||||||
|
('model_id', '=', record.model_id.id),
|
||||||
|
('translate', 'in', ['standard', 'html_translate', 'xml_translate']),
|
||||||
|
('related', '=', False),
|
||||||
|
('store', '=', True),
|
||||||
|
])
|
||||||
|
record.translatable_field_ids = translatable_fields
|
||||||
|
record.has_translatable_fields = bool(translatable_fields)
|
||||||
|
else:
|
||||||
|
record.translatable_field_ids = False
|
||||||
|
record.has_translatable_fields = False
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
records = super().create(vals_list)
|
||||||
|
records._create_server_actions()
|
||||||
|
return records
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
model_changed = 'model_id' in vals
|
||||||
|
result = super().write(vals)
|
||||||
|
if 'active' in vals or model_changed:
|
||||||
|
self._update_server_actions(model_changed=model_changed)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def unlink(self):
|
||||||
|
self.server_action_id.unlink()
|
||||||
|
return super().unlink()
|
||||||
|
|
||||||
|
def _create_server_actions(self):
|
||||||
|
"""Create server actions for each configuration."""
|
||||||
|
for record in self:
|
||||||
|
if not record.server_action_id and record.model_id:
|
||||||
|
record._create_single_server_action()
|
||||||
|
|
||||||
|
def _create_single_server_action(self):
|
||||||
|
"""Create a single server action for this configuration."""
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.model_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
action_name = _('Bulk translate')
|
||||||
|
server_action = self.env['ir.actions.server'].sudo().create({
|
||||||
|
'name': action_name,
|
||||||
|
'model_id': self.model_id.id,
|
||||||
|
'binding_model_id': self.model_id.id,
|
||||||
|
'binding_view_types': 'list',
|
||||||
|
'state': 'code',
|
||||||
|
'code': "action = env['otk.translation.config'].action_bulk_translate_generic()",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create external ID for the server action
|
||||||
|
action_xml_id = 'bulk_translate_config_action_for_' + self.model_id.model.replace('.', '_')
|
||||||
|
existing_data = self.env['ir.model.data'].sudo().search([
|
||||||
|
('module', '=', 'otoolkit_auto_translate_fields'),
|
||||||
|
('name', '=', action_xml_id),
|
||||||
|
])
|
||||||
|
if existing_data:
|
||||||
|
existing_data.unlink()
|
||||||
|
|
||||||
|
self.env['ir.model.data'].sudo().create({
|
||||||
|
'name': action_xml_id,
|
||||||
|
'model': 'ir.actions.server',
|
||||||
|
'module': 'otoolkit_auto_translate_fields',
|
||||||
|
'res_id': server_action.id,
|
||||||
|
'noupdate': True,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.server_action_id = server_action
|
||||||
|
|
||||||
|
def _update_server_actions(self, model_changed=False):
|
||||||
|
"""Update server actions when configuration changes."""
|
||||||
|
for record in self:
|
||||||
|
# If model changed, delete old action and create new one
|
||||||
|
if model_changed and record.server_action_id:
|
||||||
|
record.server_action_id.unlink()
|
||||||
|
record.server_action_id = False
|
||||||
|
|
||||||
|
if record.active and not record.server_action_id:
|
||||||
|
record._create_single_server_action()
|
||||||
|
elif not record.active and record.server_action_id:
|
||||||
|
record.server_action_id.unlink()
|
||||||
|
record.server_action_id = False
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def action_bulk_translate_generic(self):
|
||||||
|
"""Generic action to open bulk translate wizard - called from server actions."""
|
||||||
|
active_model = self.env.context.get('active_model')
|
||||||
|
if not active_model:
|
||||||
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _('Bulk translate'),
|
||||||
|
'res_model': 'otk.translation.bulk.translate',
|
||||||
|
'view_mode': 'form',
|
||||||
|
'view_id': self.env.ref('otoolkit_auto_translate_fields.view_bulk_translate_wizard_form').id,
|
||||||
|
'target': 'new',
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_enable_all_translatable_models(self):
|
||||||
|
"""Enable bulk translation for all models that have translatable fields."""
|
||||||
|
# Find all models with translatable fields
|
||||||
|
translatable_models = self.env['ir.model.fields'].search([
|
||||||
|
('translate', 'in', ['standard', 'html_translate', 'xml_translate']),
|
||||||
|
('related', '=', False),
|
||||||
|
('store', '=', True),
|
||||||
|
]).mapped('model_id')
|
||||||
|
|
||||||
|
# Filter out transient models and already configured models
|
||||||
|
existing_model_ids = self.search([]).mapped('model_id.id')
|
||||||
|
models_to_add = translatable_models.filtered(
|
||||||
|
lambda m: m.id not in existing_model_ids and not m.transient
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create configurations
|
||||||
|
vals_list = [{'model_id': model.id} for model in models_to_add]
|
||||||
|
created = self.create(vals_list)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'display_notification',
|
||||||
|
'params': {
|
||||||
|
'title': _('Bulk Translation Enabled'),
|
||||||
|
'message': _('%d models have been configured for bulk translation.') % len(created),
|
||||||
|
'type': 'success',
|
||||||
|
'sticky': False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_view_translatable_fields(self):
|
||||||
|
"""View the translatable fields for this model."""
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _('Translatable Fields'),
|
||||||
|
'res_model': 'ir.model.fields',
|
||||||
|
'view_mode': 'list,form',
|
||||||
|
'domain': [('id', 'in', self.translatable_field_ids.ids)],
|
||||||
|
'target': 'current',
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import models, api, _
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TranslationDialog(models.AbstractModel):
|
||||||
|
_name = "translation.dialog"
|
||||||
|
_description = "Translation Dialog"
|
||||||
|
|
||||||
|
def _get_api_config(self):
|
||||||
|
"""Get API configuration parameters."""
|
||||||
|
api_key = self.env['ir.config_parameter'].sudo().get_param('otoolkit_api_key')
|
||||||
|
api_endpoint = self.env['ir.config_parameter'].sudo().get_param('otoolkit.api.endpoint')
|
||||||
|
return api_key, api_endpoint
|
||||||
|
|
||||||
|
def _make_api_request(self, url, headers, payload, timeout=180):
|
||||||
|
"""Make an API request with proper error handling and logging.
|
||||||
|
|
||||||
|
Default timeout is 180 seconds (3 minutes) to allow for OpenAI processing
|
||||||
|
when translating to many languages.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = requests.request(
|
||||||
|
"POST",
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
data=payload,
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
|
return response, None
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
_logger.warning("OToolKit API request timed out: %s", url)
|
||||||
|
return None, _("The translation service is taking too long to respond. Please try again.")
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
_logger.error("OToolKit API connection error: %s - %s", url, str(e))
|
||||||
|
return None, _("Unable to connect to the translation service. Please check your internet connection.")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
_logger.error("OToolKit API request failed: %s - %s", url, str(e))
|
||||||
|
return None, _("An error occurred while contacting the translation service: %s") % str(e)
|
||||||
|
|
||||||
|
def _handle_api_response(self, response):
|
||||||
|
"""Handle API response and return appropriate result."""
|
||||||
|
if response.status_code == 200:
|
||||||
|
return [True, response.json(), ""]
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
_logger.warning("OToolKit API authentication failed (401)")
|
||||||
|
return [False, _("Your API key is invalid. This may be due to an input error, deletion or deactivation of the key. Please check your Otoolkit settings."), "settings"]
|
||||||
|
|
||||||
|
if response.status_code == 400:
|
||||||
|
try:
|
||||||
|
error = response.json()
|
||||||
|
error_type = error.get("type", "unknown")
|
||||||
|
if error_type == "empty_text":
|
||||||
|
return [False, _("The default language text cannot be empty."), ""]
|
||||||
|
if error_type == "insufficient_funds":
|
||||||
|
return [False, _("You do not have enough credits to translate. Please add credits to your balance to use this function."), "credits"]
|
||||||
|
if error_type == "invalid_object":
|
||||||
|
return [False, _("No valid text fields found to translate."), ""]
|
||||||
|
# Log unhandled 400 error types
|
||||||
|
_logger.warning("OToolKit API returned unhandled 400 error: %s", error)
|
||||||
|
return [False, _("Translation error: %s") % error.get("error", "Unknown error"), ""]
|
||||||
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
|
_logger.error("Failed to parse OToolKit API 400 response: %s", str(e))
|
||||||
|
return [False, _("The translation service returned an invalid response."), ""]
|
||||||
|
|
||||||
|
if response.status_code == 429:
|
||||||
|
_logger.warning("OToolKit API rate limited (429)")
|
||||||
|
return [False, _("Too many translation requests. Please wait a moment and try again."), ""]
|
||||||
|
|
||||||
|
if response.status_code >= 500:
|
||||||
|
_logger.error("OToolKit API server error (%d): %s", response.status_code, response.text[:500])
|
||||||
|
return [False, _("The translation service is temporarily unavailable (Error %d). Please try again later.") % response.status_code, ""]
|
||||||
|
|
||||||
|
# Log any other unexpected status codes
|
||||||
|
_logger.error("OToolKit API unexpected status code (%d): %s", response.status_code, response.text[:500])
|
||||||
|
return [False, _("Unexpected response from translation service (Error %d). Please try again.") % response.status_code, ""]
|
||||||
|
|
||||||
|
def _get_effective_value(self, term, updated_terms):
|
||||||
|
"""Get the effective value of a term, considering user edits in the dialog.
|
||||||
|
|
||||||
|
If the user edited the term (present in updated_terms), always use that
|
||||||
|
value — even if empty (the user explicitly cleared it).
|
||||||
|
"""
|
||||||
|
str_id = str(term["id"])
|
||||||
|
if str_id in updated_terms:
|
||||||
|
return (updated_terms[str_id] or "").strip()
|
||||||
|
return (term.get("value") or "").strip()
|
||||||
|
|
||||||
|
def _detect_source_and_filter(self, terms, updated_terms, source_lang_hint=None, target_lang_hint=None):
|
||||||
|
"""Detect the best source language and filter terms to only translate empty ones.
|
||||||
|
|
||||||
|
Returns (base_language, filtered_terms) or (None, []) if nothing to translate.
|
||||||
|
|
||||||
|
When the caller knows the source/target (single-term flow), it must pass
|
||||||
|
the hints — relying on auto-detection over `filled_langs` is unsafe
|
||||||
|
because `next(iter(set))` is non-deterministic and may pick the user's
|
||||||
|
target language as the source.
|
||||||
|
|
||||||
|
Auto-detection priority (used only when hints are absent):
|
||||||
|
1. User's language (if it has content)
|
||||||
|
2. en_US (Odoo's typical base language)
|
||||||
|
3. Any language other than the target hint with content
|
||||||
|
4. First language with content (last resort)
|
||||||
|
|
||||||
|
Terms whose value is identical to the source language's value are treated
|
||||||
|
as untranslated copies (Odoo duplicates the source value to all languages
|
||||||
|
by default) and will be sent for translation.
|
||||||
|
|
||||||
|
When all values are empty but source text exists (e.g. HTML fields where
|
||||||
|
translations haven't been set yet), the source text is used as the base
|
||||||
|
content for translation.
|
||||||
|
"""
|
||||||
|
user_lang = self.env.user.lang
|
||||||
|
|
||||||
|
# Build a map of lang -> effective value
|
||||||
|
lang_values = {}
|
||||||
|
for term in terms:
|
||||||
|
lang_values[term["lang"]] = self._get_effective_value(term, updated_terms)
|
||||||
|
|
||||||
|
# First pass: identify which languages have any content
|
||||||
|
filled_langs = {lang for lang, val in lang_values.items() if val}
|
||||||
|
source_from_source_field = False
|
||||||
|
|
||||||
|
if not filled_langs:
|
||||||
|
# All values are empty — fall back to source text if available
|
||||||
|
# (e.g. HTML fields where translations haven't been set yet)
|
||||||
|
for term in terms:
|
||||||
|
source = (term.get("source") or "").strip()
|
||||||
|
if source:
|
||||||
|
lang_values[term["lang"]] = source
|
||||||
|
filled_langs = {lang for lang, val in lang_values.items() if val}
|
||||||
|
if not filled_langs:
|
||||||
|
return None, []
|
||||||
|
source_from_source_field = True
|
||||||
|
|
||||||
|
# Determine source language. Explicit hints win — the JS knows which
|
||||||
|
# row the user clicked, so trust it over heuristic guessing.
|
||||||
|
if source_lang_hint and source_lang_hint in filled_langs:
|
||||||
|
source_lang = source_lang_hint
|
||||||
|
elif user_lang in filled_langs and user_lang != target_lang_hint:
|
||||||
|
source_lang = user_lang
|
||||||
|
elif "en_US" in filled_langs and "en_US" != target_lang_hint:
|
||||||
|
source_lang = "en_US"
|
||||||
|
else:
|
||||||
|
# Prefer any filled lang that is NOT the target — never pick the
|
||||||
|
# user's target as source.
|
||||||
|
non_target = [l for l in filled_langs if l != target_lang_hint]
|
||||||
|
source_lang = non_target[0] if non_target else next(iter(filled_langs))
|
||||||
|
|
||||||
|
source_value = lang_values[source_lang]
|
||||||
|
|
||||||
|
# Second pass: languages that are empty OR identical to source are
|
||||||
|
# considered untranslated (Odoo copies the source value by default)
|
||||||
|
empty_langs = set()
|
||||||
|
for lang, val in lang_values.items():
|
||||||
|
if lang == source_lang:
|
||||||
|
continue
|
||||||
|
if not val or val == source_value:
|
||||||
|
empty_langs.add(lang)
|
||||||
|
|
||||||
|
# If a target hint was provided, restrict the translation to that
|
||||||
|
# language only — a single-term click should never translate every
|
||||||
|
# untranslated peer in the dialog as a side effect.
|
||||||
|
if target_lang_hint:
|
||||||
|
empty_langs = {target_lang_hint} if target_lang_hint != source_lang else set()
|
||||||
|
|
||||||
|
if not empty_langs:
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
keep_langs = {source_lang} | empty_langs
|
||||||
|
filtered_terms = [t for t in terms if t["lang"] in keep_langs]
|
||||||
|
|
||||||
|
if source_from_source_field:
|
||||||
|
# Populate the base language terms' value with source text so the
|
||||||
|
# API receives the actual content to translate from
|
||||||
|
filtered_terms = [
|
||||||
|
{**t, "value": (t.get("source") or "")} if t["lang"] == source_lang else t
|
||||||
|
for t in filtered_terms
|
||||||
|
]
|
||||||
|
|
||||||
|
return source_lang, filtered_terms
|
||||||
|
|
||||||
|
def otoolkit_api_translation(self, terms, updated_terms, source_lang_hint=None, target_lang_hint=None):
|
||||||
|
api_key, api_endpoint = self._get_api_config()
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
return [False, _("Your API key is invalid. This may be due to an input error, deletion or deactivation of the key. Please check your Otoolkit settings."), "settings"]
|
||||||
|
|
||||||
|
if not api_endpoint:
|
||||||
|
_logger.error("OToolKit API endpoint not configured")
|
||||||
|
return [False, _("The translation service is not configured. Please contact your administrator."), ""]
|
||||||
|
|
||||||
|
base_language, filtered_terms = self._detect_source_and_filter(
|
||||||
|
terms, updated_terms,
|
||||||
|
source_lang_hint=source_lang_hint,
|
||||||
|
target_lang_hint=target_lang_hint,
|
||||||
|
)
|
||||||
|
if not base_language:
|
||||||
|
return [True, [], ""]
|
||||||
|
|
||||||
|
payload = json.dumps({
|
||||||
|
"terms": filtered_terms,
|
||||||
|
"updated_terms": updated_terms,
|
||||||
|
"base_language": base_language,
|
||||||
|
"odoo_user_id": self.env.user.id
|
||||||
|
})
|
||||||
|
headers = {
|
||||||
|
'Odoo-Api-Key': api_key,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
url = f"{api_endpoint}/api/auto-translate-fields/translate/"
|
||||||
|
|
||||||
|
response, error_message = self._make_api_request(url, headers, payload)
|
||||||
|
|
||||||
|
if error_message:
|
||||||
|
return [False, error_message, ""]
|
||||||
|
|
||||||
|
return self._handle_api_response(response)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def translate_text(self, terms, updated_terms, source_lang_hint=None, target_lang_hint=None):
|
||||||
|
return self.otoolkit_api_translation(
|
||||||
|
terms, updated_terms,
|
||||||
|
source_lang_hint=source_lang_hint,
|
||||||
|
target_lang_hint=target_lang_hint,
|
||||||
|
)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from odoo import models, fields, api, _
|
||||||
|
|
||||||
|
|
||||||
|
class TranslationMixin(models.AbstractModel):
|
||||||
|
_name = 'otk.translation.mixin'
|
||||||
|
_description = 'Translation Mixin'
|
||||||
|
|
||||||
|
last_translation_date = fields.Datetime('Last Translation Date')
|
||||||
|
|
||||||
|
def action_bulk_translate(self):
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _('Bulk translate'),
|
||||||
|
'res_model': 'otk.translation.bulk.translate',
|
||||||
|
'view_mode': 'form',
|
||||||
|
'view_id': self.env.ref('otoolkit_auto_translate_fields.view_bulk_translate_wizard_form').id,
|
||||||
|
'target': 'new',
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_view(self, view_id=None, view_type='search', **options):
|
||||||
|
arch, view = super()._get_view(view_id, view_type, **options)
|
||||||
|
if view_type == 'list':
|
||||||
|
# Here we check if the action already exists
|
||||||
|
model_name = self._name
|
||||||
|
action_xml_id = 'bulk_translate_action_for_' + model_name.replace('.', '_')
|
||||||
|
|
||||||
|
server_action = self.env.ref(f'otoolkit_auto_translate_fields.{action_xml_id}', raise_if_not_found=False)
|
||||||
|
|
||||||
|
if not server_action:
|
||||||
|
# Create the server action dynamically
|
||||||
|
model = self.env['ir.model']._get(model_name)
|
||||||
|
action_name = _('Bulk translate')
|
||||||
|
server_action = self.env['ir.actions.server'].sudo().create({
|
||||||
|
'name': action_name,
|
||||||
|
'model_id': model.id,
|
||||||
|
'binding_model_id': model.id,
|
||||||
|
'binding_view_types': 'list',
|
||||||
|
'state': 'code',
|
||||||
|
'code': "action = env['%s'].action_bulk_translate()" % model_name,
|
||||||
|
})
|
||||||
|
self.env['ir.model.data'].sudo().create({
|
||||||
|
'name': action_xml_id,
|
||||||
|
'model': 'ir.actions.server',
|
||||||
|
'module': "otoolkit_auto_translate_fields",
|
||||||
|
'res_id': server_action.id,
|
||||||
|
'noupdate': True,
|
||||||
|
})
|
||||||
|
return arch, view
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_bulk_translate,access_bulk_translate,model_otk_translation_bulk_translate,base.group_user,1,1,1,1
|
||||||
|
access_translation_action,access_translation_action,model_otk_translation_action,base.group_user,1,1,1,1
|
||||||
|
access_translation,access_translation,model_otk_translation,base.group_user,1,1,1,1
|
||||||
|
access_translation_config,access_translation_config,model_otk_translation_config,base.group_system,1,1,1,1
|
||||||
|
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 536 KiB |
|
After Width: | Height: | Size: 961 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
@@ -0,0 +1,71 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.4.1/dist/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="row position-relative pt-lg-3 px-lg-5 pb-lg-3 p-md-4 p-3 mx-auto d-flex align-items-center" style="color:#333333; font-weight:500; font-size:16px; width:95%">
|
||||||
|
<section class="oe_container">
|
||||||
|
<div class="col-md-12 text-center" style="margin-top: 10px;">
|
||||||
|
<a target="_blank" href="https://otoolkit.app">
|
||||||
|
<h2>Otoolkit</h2>
|
||||||
|
</a>
|
||||||
|
<p>
|
||||||
|
<a href="https://otoolkit.app" target="_blank">Website: otoolkit.app</a><br>
|
||||||
|
Email: <a href="mailto:contact@otoolkit.app">contact@otoolkit.app</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12 text-center" style="margin-top: 30px;">
|
||||||
|
<a target="_blank" href="https://otoolkit.app/apps/auto-translate-fields">
|
||||||
|
<h2>Auto Translate Fields</h2>
|
||||||
|
</a>
|
||||||
|
<p>The <strong>Auto Translate Fields</strong> module enhances the translation process in Odoo by adding a convenient "Translate" button to the translation popup of any translatable field. When clicked, it automatically translates the field content into all configured languages in the system, using the current user's language as the source. This feature simplifies and accelerates the workflow for multilingual environments, ensuring that translations are consistent and up-to-date across the platform. Ideal for businesses that operate in multiple languages, this module improves efficiency by reducing manual translation efforts.</p>
|
||||||
|
<p>Instructions on how to use this module can be found at <a href="https://otoolkit.app/apps/auto-translate-fields">https://otoolkit.app/apps/auto-translate-fields</a></p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12 text-center" style="margin-top: 30px;text-align: left !important;">
|
||||||
|
<h2 style="text-align: center !important">
|
||||||
|
<b>How it works</b>
|
||||||
|
</h2>
|
||||||
|
<div class="row text-dark">
|
||||||
|
<div class="col-lg-8 col-sm-12 mt-5 mb-5">
|
||||||
|
<img src="assets/translate-field.webp" class="shadow" style="width: 100%; border-radius: 15px;" alt="Classic text input with auto translate in odoo">
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 col-sm-12 mt-5 mb-5" style="display: flex; align-items: center;">
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
<b>Auto Translate</b>
|
||||||
|
</h3>
|
||||||
|
<p class="mt-4">When you translate your fields, you'll find a new button called «Auto Translate». Click on it to translate your text into all the languages of your instance. The user's base language is used as the starting language.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="d-lg-none">
|
||||||
|
|
||||||
|
<div class="col-lg-4 col-sm-12 mt-5 mb-5" style="display: flex; align-items: center;">
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
<b>Also works with HTML fields</b>
|
||||||
|
</h3>
|
||||||
|
<p class="mt-4">Automatic translation also works with HTML fields. This saves time by avoiding line-by-line translation.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-8 col-sm-12 mt-5 mb-5">
|
||||||
|
<img src="assets/markdown-input.webp" class="shadow" style="width: 100%; border-radius: 15px;" alt="Markdown input with auto translate in odoo">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12 text-center mt-5 p-4" style="background-color: #f8f9fa; border-radius: 15px; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);">
|
||||||
|
<h3 style="color: rgb(124, 58, 237);">Have any questions?</h3>
|
||||||
|
<p class="mt-3" style="color: #333;">If you have any questions or need more information, feel free to visit our website or contact us directly.</p>
|
||||||
|
<p>
|
||||||
|
<span href="https://otoolkit.app" class="btn otoolkit-open-site" style="background-color: rgb(124, 58, 237); color: #fff; border-radius: 5px; padding: 10px 20px; text-decoration: none;">Visit Our Website</span>
|
||||||
|
<a href="mailto:contact@otoolkit.app" class="btn" style="background-color: #fff; color: rgb(124, 58, 237); border: 2px solid rgb(124, 58, 237); border-radius: 5px; padding: 10px 20px; text-decoration: none;">Email Us</a>
|
||||||
|
</p>
|
||||||
|
<a href="https://otoolkit.app">https://otoolkit.app</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||||
|
import { Component, xml } from "@odoo/owl";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
|
||||||
|
export class JsonPrettyField extends Component {
|
||||||
|
static template = xml`
|
||||||
|
<p style="white-space: pre-wrap; background: #f5f5f5; padding: 12px; border-radius: 6px;">
|
||||||
|
<t t-esc="this.prettyJson"/>
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
static props = standardFieldProps;
|
||||||
|
|
||||||
|
get prettyJson() {
|
||||||
|
try {
|
||||||
|
const value = this.props.record.data[this.props.name];
|
||||||
|
return JSON.stringify(value ?? {}, null, 4);
|
||||||
|
} catch (e) {
|
||||||
|
return "Invalid JSON";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.category("fields").add("json_pretty", { component: JsonPrettyField });
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { TranslationDialog } from "@web/views/fields/translation_dialog";
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { useState } from "@odoo/owl";
|
||||||
|
|
||||||
|
patch(TranslationDialog.prototype, {
|
||||||
|
setup() {
|
||||||
|
super.setup(...arguments);
|
||||||
|
this.orm = useService('orm');
|
||||||
|
this.notification = useService("notification");
|
||||||
|
this.action = useService("action");
|
||||||
|
this.state = useState({ translating: false, translatingTermIds: {} });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the best source term to translate from for a given target term.
|
||||||
|
* Picks a term in a different language with the same source text.
|
||||||
|
* Priority: any candidate with actual translated content > en_US > first
|
||||||
|
* candidate. en_US is deprioritized because Odoo populates en_US from the
|
||||||
|
* record's base value — which may itself be in another language (e.g. a
|
||||||
|
* record edited primarily in French) and would mislabel the source to GPT.
|
||||||
|
*/
|
||||||
|
_findSourceTerm(targetTerm) {
|
||||||
|
const candidates = this.terms.filter(
|
||||||
|
t => t.lang !== targetTerm.lang && t.source === targetTerm.source
|
||||||
|
);
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
return candidates.find(t => (this.updatedTerms[t.id] ?? t.value ?? "").trim())
|
||||||
|
|| candidates.find(t => t.lang === "en_US")
|
||||||
|
|| candidates[0];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle translation error notifications.
|
||||||
|
* Returns true if an error was displayed (caller should stop).
|
||||||
|
*/
|
||||||
|
_handleTranslationNotification(result) {
|
||||||
|
if (!result[0]) {
|
||||||
|
if (result[2] === "credits") {
|
||||||
|
this.notification.add(result[1], {
|
||||||
|
type: "danger",
|
||||||
|
buttons: [{
|
||||||
|
onClick: () => window.open("https://osolutions.app/pricing"),
|
||||||
|
primary: false,
|
||||||
|
name: _t("Get tokens"),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
} else if (result[2] === "settings") {
|
||||||
|
this.notification.add(result[1], {
|
||||||
|
type: "danger",
|
||||||
|
buttons: [{
|
||||||
|
onClick: () => this.action.doAction({
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
res_model: "res.config.settings",
|
||||||
|
views: [[false, "form"]],
|
||||||
|
target: "current",
|
||||||
|
context: { module: "otoolkit_auth" },
|
||||||
|
}),
|
||||||
|
primary: false,
|
||||||
|
name: _t("Open Settings"),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.notification.add(result[1], { type: "danger" });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply translated terms to the dialog state.
|
||||||
|
* Returns the count of terms updated.
|
||||||
|
*/
|
||||||
|
_applyTranslationResults(translatedTerms) {
|
||||||
|
let count = 0;
|
||||||
|
for (const item of translatedTerms) {
|
||||||
|
if (item.translation) {
|
||||||
|
this.updatedTerms[item.id] = item.translation;
|
||||||
|
const target = this.terms.find(t => t.id === item.id);
|
||||||
|
if (target) {
|
||||||
|
target.value = item.translation;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether any translation (bulk or single-term) is currently running.
|
||||||
|
*/
|
||||||
|
get anyTranslating() {
|
||||||
|
if (this.state.translating) return true;
|
||||||
|
for (const id in this.state.translatingTermIds) {
|
||||||
|
if (this.state.translatingTermIds[id]) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk auto-translate all empty terms.
|
||||||
|
*/
|
||||||
|
async autoTranslate() {
|
||||||
|
this.state.translating = true;
|
||||||
|
try {
|
||||||
|
const result = await this._translateText(this.terms, this.updatedTerms, null, null);
|
||||||
|
if (this._handleTranslationNotification(result)) return;
|
||||||
|
|
||||||
|
if (result[1].length === 0) {
|
||||||
|
return this.notification.add(
|
||||||
|
_t("All translations are already filled."), { type: "info" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = this._applyTranslationResults(result[1]);
|
||||||
|
this.render();
|
||||||
|
if (count > 0) {
|
||||||
|
this.notification.add(_t("Successfully translated"), { type: "success" });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.state.translating = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate a single empty term.
|
||||||
|
*/
|
||||||
|
async translateSingleTerm(targetTerm) {
|
||||||
|
const sourceTerm = this._findSourceTerm(targetTerm);
|
||||||
|
if (!sourceTerm) {
|
||||||
|
return this.notification.add(
|
||||||
|
_t("No other language available to translate from."), { type: "warning" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.translatingTermIds[targetTerm.id] = true;
|
||||||
|
try {
|
||||||
|
const result = await this._translateText(
|
||||||
|
[sourceTerm, targetTerm], this.updatedTerms,
|
||||||
|
sourceTerm.lang, targetTerm.lang,
|
||||||
|
);
|
||||||
|
if (this._handleTranslationNotification(result)) return;
|
||||||
|
|
||||||
|
if (result[1].length === 0) {
|
||||||
|
return this.notification.add(
|
||||||
|
_t("No translation returned for this term."), { type: "warning" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply only the target term's translation. The API also returns
|
||||||
|
// an "echo" entry for the source-language term (its `translation`
|
||||||
|
// is the source text itself) — applying it would overwrite the
|
||||||
|
// source row's displayed value, which is wrong when the source
|
||||||
|
// peer is an empty row whose displayed `value` is empty (HTML
|
||||||
|
// showSource case).
|
||||||
|
const targetOnly = result[1].filter(item => item.id === targetTerm.id);
|
||||||
|
const count = this._applyTranslationResults(targetOnly);
|
||||||
|
this.render();
|
||||||
|
if (count > 0) {
|
||||||
|
this.notification.add(_t("Successfully translated"), { type: "success" });
|
||||||
|
} else {
|
||||||
|
this.notification.add(
|
||||||
|
_t("No translation returned for this term."), { type: "warning" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
delete this.state.translatingTermIds[targetTerm.id];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async _translateText(terms, updated_terms, source_lang_hint = null, target_lang_hint = null) {
|
||||||
|
return await this.orm.call(
|
||||||
|
"translation.dialog", "translate_text",
|
||||||
|
[terms, updated_terms, source_lang_hint, target_lang_hint],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async onSave() {
|
||||||
|
const translations = {};
|
||||||
|
|
||||||
|
this.terms.forEach((term) => {
|
||||||
|
const updatedTermValue = this.updatedTerms[term.id];
|
||||||
|
|
||||||
|
if (term.id in this.updatedTerms && updatedTermValue !== term.originalValue) {
|
||||||
|
if (this.props.showSource) {
|
||||||
|
if (!translations[term.lang]) {
|
||||||
|
translations[term.lang] = {};
|
||||||
|
}
|
||||||
|
const oldTermValue = term.originalValue ? term.originalValue : term.source;
|
||||||
|
translations[term.lang][oldTermValue] = updatedTermValue || term.source;
|
||||||
|
} else {
|
||||||
|
translations[term.lang] = updatedTermValue || false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.orm.call(this.props.resModel, "update_field_translations", [
|
||||||
|
[this.props.resId],
|
||||||
|
this.props.fieldName,
|
||||||
|
translations,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await this.props.onSave();
|
||||||
|
this.props.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates>
|
||||||
|
<t t-inherit="web.TranslationDialog" t-inherit-mode="extension">
|
||||||
|
<!-- Make translation column a flex container for inline button -->
|
||||||
|
<xpath expr="//div[hasclass('translation')]" position="attributes">
|
||||||
|
<attribute name="class" add="d-flex align-items-start gap-1" separator=" "/>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Make textarea fill remaining space -->
|
||||||
|
<xpath expr="//div[hasclass('translation')]//textarea" position="attributes">
|
||||||
|
<attribute name="class" add="flex-grow-1" separator=" "/>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Make input fill remaining space -->
|
||||||
|
<xpath expr="//div[hasclass('translation')]//input[@type='text']" position="attributes">
|
||||||
|
<attribute name="class" add="flex-grow-1" separator=" "/>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Single-term translate button (shown only for empty terms) -->
|
||||||
|
<xpath expr="//div[hasclass('translation')]" position="inside">
|
||||||
|
<button t-if="!term.value"
|
||||||
|
class="btn btn-sm btn-outline-primary px-2 o_translate_single_btn flex-shrink-0"
|
||||||
|
t-on-click="() => this.translateSingleTerm(term)"
|
||||||
|
t-att-disabled="anyTranslating"
|
||||||
|
t-att-title="'Translate to ' + term.langName">
|
||||||
|
<t t-if="state.translatingTermIds[term.id]">
|
||||||
|
<i class="fa fa-spinner fa-spin"/>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<i class="fa fa-language"/>
|
||||||
|
</t>
|
||||||
|
</button>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Footer: Save, Discard, Auto Translate -->
|
||||||
|
<xpath expr="//t[@t-set-slot='footer']" position="replace">
|
||||||
|
<t t-set-slot="footer">
|
||||||
|
<button class="btn btn-primary" t-on-click="onSave">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" t-on-click="props.close">
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary ml-3" t-on-click="autoTranslate" t-att-disabled="anyTranslating">
|
||||||
|
<t t-if="state.translating">
|
||||||
|
<i class="fa fa-spinner fa-spin me-1"/>Translating...
|
||||||
|
</t>
|
||||||
|
<t t-else="">Auto Translate</t>
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_otk_translation_config_list" model="ir.ui.view">
|
||||||
|
<field name="name">otk.translation.config.list</field>
|
||||||
|
<field name="model">otk.translation.config</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list>
|
||||||
|
<field name="model_id"/>
|
||||||
|
<field name="has_translatable_fields"/>
|
||||||
|
<field name="translatable_field_ids" widget="many2many_tags"/>
|
||||||
|
<field name="active" widget="boolean_toggle"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_otk_translation_config_form" model="ir.ui.view">
|
||||||
|
<field name="name">otk.translation.config.form</field>
|
||||||
|
<field name="model">otk.translation.config</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form>
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_button_box" name="button_box">
|
||||||
|
<button name="action_view_translatable_fields"
|
||||||
|
type="object"
|
||||||
|
class="oe_stat_button"
|
||||||
|
icon="fa-list"
|
||||||
|
invisible="not has_translatable_fields">
|
||||||
|
<field name="has_translatable_fields" invisible="1"/>
|
||||||
|
<span>Translatable Fields</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="model_id" options="{'no_create': True}"/>
|
||||||
|
<field name="active" widget="boolean_toggle"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="server_action_id" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Translatable Fields" invisible="not has_translatable_fields">
|
||||||
|
<field name="translatable_field_ids" widget="many2many_tags" nolabel="1" colspan="2" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<div class="alert alert-warning" role="alert" invisible="has_translatable_fields">
|
||||||
|
<strong>Warning:</strong> This model has no translatable fields. Bulk translation will not be useful.
|
||||||
|
</div>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_otk_translation_config_search" model="ir.ui.view">
|
||||||
|
<field name="name">otk.translation.config.search</field>
|
||||||
|
<field name="model">otk.translation.config</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search>
|
||||||
|
<field name="model_id"/>
|
||||||
|
<filter name="active" string="Active" domain="[('active', '=', True)]"/>
|
||||||
|
<filter name="inactive" string="Archived" domain="[('active', '=', False)]"/>
|
||||||
|
<filter name="has_fields" string="With Translatable Fields" domain="[('has_translatable_fields', '=', True)]"/>
|
||||||
|
<group>
|
||||||
|
<filter string="Active" name="group_active" context="{'group_by': 'active'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_otk_translation_config" model="ir.actions.act_window">
|
||||||
|
<field name="name">Translation Configuration</field>
|
||||||
|
<field name="res_model">otk.translation.config</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="context">{'search_default_active': 1}</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Configure models for bulk translation
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Add models here to enable the "Bulk translate" action on their list views.
|
||||||
|
No code changes required - just select a model and it will automatically
|
||||||
|
get the bulk translation feature.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Wizard to enable all translatable models at once -->
|
||||||
|
<record id="action_enable_all_translatable_models" model="ir.actions.server">
|
||||||
|
<field name="name">Enable All Translatable Models</field>
|
||||||
|
<field name="model_id" ref="model_otk_translation_config"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">action = model.action_enable_all_translatable_models()</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem
|
||||||
|
id="otoolkit_translation_config_menu"
|
||||||
|
name="Translation Settings"
|
||||||
|
parent="otoolkit_menu"
|
||||||
|
action="action_otk_translation_config"
|
||||||
|
sequence="120"
|
||||||
|
/>
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_otoolkit_translation_action_list" model="ir.ui.view">
|
||||||
|
<field name="name">otk.translation.action.list</field>
|
||||||
|
<field name="model">otk.translation.action</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list>
|
||||||
|
<field name="id"/>
|
||||||
|
<field name="model_id" />
|
||||||
|
<field name="field_ids" widget="many2many_tags" />
|
||||||
|
<field name="translation_count" />
|
||||||
|
<field name="skipped_count" optional="show"/>
|
||||||
|
<field name="token_cost" />
|
||||||
|
<field name="status" />
|
||||||
|
<field name="progress" widget="progressbar"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_otoolkit_translation_action_form" model="ir.ui.view">
|
||||||
|
<field name="name">otk.translation.action.form</field>
|
||||||
|
<field name="model">otk.translation.action</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form>
|
||||||
|
<header>
|
||||||
|
<button invisible="status!='error'" name="retry_error_translation" string="Retry translation" type="object" class="btn-primary" />
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<!-- Skipped records alert -->
|
||||||
|
<div class="alert alert-warning" role="alert"
|
||||||
|
invisible="skipped_count == 0">
|
||||||
|
<strong>
|
||||||
|
<i class="fa fa-exclamation-triangle" title="Skipped records"/>
|
||||||
|
<field name="skipped_count" class="d-inline"/> records skipped
|
||||||
|
</strong>
|
||||||
|
— these records had no translatable content in the selected fields and were not sent to the API (no tokens consumed).
|
||||||
|
</div>
|
||||||
|
<!-- Server-side processing alert -->
|
||||||
|
<div class="alert alert-info" role="alert"
|
||||||
|
invisible="status != 'processing' or processing_count == 0">
|
||||||
|
<strong>
|
||||||
|
<i class="fa fa-spinner" title="Translating"/>
|
||||||
|
<field name="processing_count" class="d-inline"/> record(s) being translated on the O'Toolkit server.
|
||||||
|
</strong>
|
||||||
|
Large contents can take several minutes. Results are fetched automatically every few minutes —
|
||||||
|
last check: <field name="last_server_check" class="d-inline"/>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group string="Configuration">
|
||||||
|
<field name="model_id" />
|
||||||
|
<field name="status" />
|
||||||
|
<field name="base_language_id" />
|
||||||
|
<field name="target_language_ids" widget="many2many_tags" />
|
||||||
|
<field name="field_ids" widget="many2many_tags" />
|
||||||
|
<field name="overwrite_existing" />
|
||||||
|
</group>
|
||||||
|
<group string="Progress">
|
||||||
|
<field name="translation_count" />
|
||||||
|
<field name="token_cost" />
|
||||||
|
<field name="progress" widget="progressbar" />
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Translations" name="translations">
|
||||||
|
<field name="translation_ids">
|
||||||
|
<list create="0" delete="0">
|
||||||
|
<field name="record_id" />
|
||||||
|
<field name="cost" />
|
||||||
|
<field name="status" />
|
||||||
|
<field name="last_server_check" optional="show" />
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
<page string="Record IDs" name="record_ids">
|
||||||
|
<group>
|
||||||
|
<field name="pending_record_ids" widget="jsonb" />
|
||||||
|
<field name="processing_record_ids" widget="jsonb" />
|
||||||
|
<field name="done_record_ids" widget="jsonb" />
|
||||||
|
<field name="error_record_ids" widget="jsonb" />
|
||||||
|
<field name="skipped_record_ids" widget="jsonb" />
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_otoolkit_translation_list" model="ir.ui.view">
|
||||||
|
<field name="name">otk.translation.list</field>
|
||||||
|
<field name="model">otk.translation</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list>
|
||||||
|
<field name="id"/>
|
||||||
|
<field name="model_id" />
|
||||||
|
<field name="record_id" />
|
||||||
|
<field name="action_id" />
|
||||||
|
<field name="status" />
|
||||||
|
<field name="last_server_check" optional="show" />
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_otoolkit_translation_form" model="ir.ui.view">
|
||||||
|
<field name="name">otk.translation.form</field>
|
||||||
|
<field name="model">otk.translation</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form>
|
||||||
|
<header>
|
||||||
|
<button invisible="status!='success'" name="revert_translation" string="Revert translation" type="object" class="btn-primary" />
|
||||||
|
<button name="open_record" string="Open record" type="object" />
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<field name="model_id" />
|
||||||
|
<field name="record_id" />
|
||||||
|
<field name="status" />
|
||||||
|
<field name="last_server_check" invisible="status != 'pending'" />
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Translations">
|
||||||
|
<field name="translations" widget="json_pretty" />
|
||||||
|
</page>
|
||||||
|
<page string="Initial value" invisible="status!='success' and status!='revert'">
|
||||||
|
<field name="initial_values" widget="json_pretty" />
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_otoolkit_translation" model="ir.actions.act_window">
|
||||||
|
<field name="name">Translation actions</field>
|
||||||
|
<field name="res_model">otk.translation</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_otoolkit_translation_action" model="ir.actions.act_window">
|
||||||
|
<field name="name">Translation actions</field>
|
||||||
|
<field name="res_model">otk.translation.action</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem
|
||||||
|
id="otoolkit_menu"
|
||||||
|
name="Otoolkit"
|
||||||
|
parent="base.menu_custom"
|
||||||
|
sequence="100"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<menuitem
|
||||||
|
id="otoolkit_translation_actions_menu"
|
||||||
|
name="Bulk translations"
|
||||||
|
parent="otoolkit_menu"
|
||||||
|
action="action_otoolkit_translation_action"
|
||||||
|
sequence="105"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<menuitem
|
||||||
|
id="otoolkit_translation_menu"
|
||||||
|
name="Past translations"
|
||||||
|
parent="otoolkit_menu"
|
||||||
|
action="action_otoolkit_translation"
|
||||||
|
sequence="110"
|
||||||
|
/>
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import bulk_translate
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
from odoo import fields, models, api, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
from ..models.translation import _is_valid_translatable_value
|
||||||
|
|
||||||
|
|
||||||
|
class BulkTranslate(models.TransientModel):
|
||||||
|
_name = 'otk.translation.bulk.translate'
|
||||||
|
_description = "Bulk translate Wizard"
|
||||||
|
|
||||||
|
model_id = fields.Many2one('ir.model', string="Model", ondelete='cascade')
|
||||||
|
base_language_id = fields.Many2one('res.lang', string="Base language", required=True, ondelete='cascade')
|
||||||
|
target_language_ids = fields.Many2many('res.lang', string="Target languages", required=True, ondelete='cascade')
|
||||||
|
field_ids = fields.Many2many(
|
||||||
|
'ir.model.fields',
|
||||||
|
string="Fields",
|
||||||
|
domain="[('translate', 'in', ['standard', 'html_translate', 'xml_translate']), ('model_id', '=', model_id), ('related', '=', False), ('store', '=', True)]",
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
overwrite_existing = fields.Boolean(
|
||||||
|
string="Overwrite existing translations",
|
||||||
|
default=False,
|
||||||
|
help="Re-translate every term, including terms that already have a "
|
||||||
|
"translation in the target languages.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('model_id')
|
||||||
|
def _compute_model_name(self):
|
||||||
|
for record in self:
|
||||||
|
record.model_name = record.model_id.model
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def default_get(self, fields_list):
|
||||||
|
defaults = super().default_get(fields_list)
|
||||||
|
active_model = self.env.context.get('active_model')
|
||||||
|
if active_model:
|
||||||
|
model = self.env['ir.model'].search([('model', '=', active_model)], limit=1)
|
||||||
|
defaults['model_id'] = model.id
|
||||||
|
defaults['base_language_id'] = self.env['res.lang'].search([('code', '=', self.env.user.lang)], limit=1)
|
||||||
|
defaults['target_language_ids'] = self.env['res.lang'].search([('code', '!=', self.env.user.lang)])
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
def _resolve_field_value(self, model_name, record_id, field_name, base_lang_code):
|
||||||
|
"""Resolve a translatable field value with language fallback.
|
||||||
|
|
||||||
|
Tries base_lang first, then en_US. Handles html_translate fields that
|
||||||
|
may return False for a specific lang context.
|
||||||
|
"""
|
||||||
|
candidates = [base_lang_code]
|
||||||
|
if base_lang_code != 'en_US':
|
||||||
|
candidates.append('en_US')
|
||||||
|
|
||||||
|
for try_lang in candidates:
|
||||||
|
record = self.env[model_name].with_context(lang=try_lang).browse(record_id)
|
||||||
|
if not record.exists():
|
||||||
|
return False
|
||||||
|
value = getattr(record, field_name, False)
|
||||||
|
if _is_valid_translatable_value(value):
|
||||||
|
return value
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _pre_filter_records(self, active_ids):
|
||||||
|
"""Pre-filter records to separate translatable from empty ones.
|
||||||
|
|
||||||
|
Uses language fallback (base_lang → en_US) to handle html_translate
|
||||||
|
fields that may not resolve for a specific lang context.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (translatable_ids, skipped_ids)
|
||||||
|
"""
|
||||||
|
model_name = self.model_id.model
|
||||||
|
base_lang_code = self.base_language_id.code
|
||||||
|
|
||||||
|
translatable_ids = []
|
||||||
|
skipped_ids = []
|
||||||
|
|
||||||
|
for record_id in active_ids:
|
||||||
|
has_content = False
|
||||||
|
for field in self.field_ids:
|
||||||
|
value = self._resolve_field_value(
|
||||||
|
model_name, record_id, field.name, base_lang_code
|
||||||
|
)
|
||||||
|
if _is_valid_translatable_value(value):
|
||||||
|
has_content = True
|
||||||
|
break
|
||||||
|
if has_content:
|
||||||
|
translatable_ids.append(record_id)
|
||||||
|
else:
|
||||||
|
skipped_ids.append(record_id)
|
||||||
|
|
||||||
|
return translatable_ids, skipped_ids
|
||||||
|
|
||||||
|
def action_translate(self):
|
||||||
|
active_ids = self.env.context.get('active_ids', [])
|
||||||
|
|
||||||
|
if not active_ids:
|
||||||
|
raise UserError(_("No records selected for translation."))
|
||||||
|
|
||||||
|
# Pre-filter: separate records with content from empty ones
|
||||||
|
translatable_ids, skipped_ids = self._pre_filter_records(active_ids)
|
||||||
|
|
||||||
|
if not translatable_ids:
|
||||||
|
raise UserError(_(
|
||||||
|
"None of the %d selected records have translatable content "
|
||||||
|
"in the selected fields (%s).\n\n"
|
||||||
|
"Please check that the records have content in the base language (%s)."
|
||||||
|
) % (
|
||||||
|
len(active_ids),
|
||||||
|
', '.join(self.field_ids.mapped('field_description')),
|
||||||
|
self.base_language_id.name,
|
||||||
|
))
|
||||||
|
|
||||||
|
self.env['otk.translation.action'].sudo().create({
|
||||||
|
'model_id': self.model_id.id,
|
||||||
|
'base_language_id': self.base_language_id.id,
|
||||||
|
'target_language_ids': self.target_language_ids.ids,
|
||||||
|
'field_ids': self.field_ids.ids,
|
||||||
|
'overwrite_existing': self.overwrite_existing,
|
||||||
|
'pending_record_ids': translatable_ids,
|
||||||
|
'skipped_record_ids': skipped_ids,
|
||||||
|
'translation_count': len(translatable_ids),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Show summary notification if some records were skipped
|
||||||
|
if skipped_ids:
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'display_notification',
|
||||||
|
'params': {
|
||||||
|
'title': _("Translation Started"),
|
||||||
|
'message': _(
|
||||||
|
"%d records queued for translation.\n"
|
||||||
|
"%d records skipped (no translatable content in selected fields)."
|
||||||
|
) % (len(translatable_ids), len(skipped_ids)),
|
||||||
|
'type': 'warning',
|
||||||
|
'sticky': True,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_bulk_translate_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">translation.bulk.translate.wizard.form</field>
|
||||||
|
<field name="model">otk.translation.bulk.translate</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Bulk translate">
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<field invisible="1" name="model_id" />
|
||||||
|
<field options="{'no_create': True, 'no_open': True}" name="field_ids" widget="many2many_tags" />
|
||||||
|
<field options="{'no_create': True, 'no_open': True}" name="base_language_id" />
|
||||||
|
<field options="{'no_create': True, 'no_open': True}" name="target_language_ids" widget="many2many_tags" />
|
||||||
|
<field name="overwrite_existing" />
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
<footer>
|
||||||
|
<button string="Translate" name="action_translate" type="object" class="btn-primary"/>
|
||||||
|
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from . import models
|
||||||
|
from . import wizards
|
||||||
|
from . import controllers
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
'name': "O'Toolkit SEO Content Generator",
|
||||||
|
'version': '19.0.1.0.1',
|
||||||
|
'category': 'Marketing',
|
||||||
|
'summary': 'AI-powered SEO content and blog post generation',
|
||||||
|
'description': """
|
||||||
|
O'Toolkit SEO Content Generator
|
||||||
|
===============================
|
||||||
|
|
||||||
|
An AI-powered content creation suite for Odoo that helps users generate,
|
||||||
|
optimize, and manage SEO-friendly blog content directly within their ERP.
|
||||||
|
|
||||||
|
Features
|
||||||
|
--------
|
||||||
|
* Generate SEO-optimized blog posts from keywords and topics
|
||||||
|
* AI-powered image generation for content
|
||||||
|
* SEO analysis and optimization suggestions
|
||||||
|
* Multi-language support via otoolkit_auto_translate_fields
|
||||||
|
* Product description generation
|
||||||
|
* Content templates for consistent brand voice
|
||||||
|
* Version history with rollback capability
|
||||||
|
* Deep integration with Odoo Website Blog
|
||||||
|
* URL reference content fetching (auto-fetch web page content)
|
||||||
|
* Batch content generation with item preservation
|
||||||
|
* Ideas Queue for scheduled content generation
|
||||||
|
* Content-aware image prompts for better image generation
|
||||||
|
* Auto-insert images into blog content
|
||||||
|
|
||||||
|
Powered by O'Toolkit API with OpenAI GPT-*-mini for text and GPT-image-1.5 for images.
|
||||||
|
|
||||||
|
Optional Dependencies
|
||||||
|
---------------------
|
||||||
|
* beautifulsoup4: Enhanced HTML parsing for URL reference fetching (install with: pip install beautifulsoup4)
|
||||||
|
""",
|
||||||
|
'author': "O'Solutions Company",
|
||||||
|
'website': 'https://osolutions.app/products/o-toolkit',
|
||||||
|
'license': 'OPL-1',
|
||||||
|
'depends': [
|
||||||
|
'base',
|
||||||
|
'web',
|
||||||
|
'bus',
|
||||||
|
'mail',
|
||||||
|
'otoolkit_auth',
|
||||||
|
'otoolkit_auto_translate_fields',
|
||||||
|
'website',
|
||||||
|
'website_blog',
|
||||||
|
],
|
||||||
|
'external_dependencies': {
|
||||||
|
'python': ['requests'],
|
||||||
|
},
|
||||||
|
'data': [
|
||||||
|
# Data
|
||||||
|
'data/ir_cron.xml',
|
||||||
|
'data/seo_templates.xml',
|
||||||
|
'data/brand_voices.xml',
|
||||||
|
'data/ir_module_category.xml',
|
||||||
|
'data/translation_configs.xml',
|
||||||
|
# Security
|
||||||
|
'security/seo_security.xml',
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
# Views
|
||||||
|
'views/seo_content_views.xml',
|
||||||
|
'views/seo_content_image_views.xml',
|
||||||
|
'views/seo_content_batch_views.xml',
|
||||||
|
'views/seo_content_idea_views.xml',
|
||||||
|
'views/seo_template_views.xml',
|
||||||
|
'views/brand_voice_views.xml',
|
||||||
|
'views/seo_reference_views.xml',
|
||||||
|
'views/seo_dashboard_views.xml',
|
||||||
|
'views/res_config_settings_views.xml',
|
||||||
|
# Wizards
|
||||||
|
'wizards/generate_content_views.xml',
|
||||||
|
# Menu items
|
||||||
|
'views/menu_views.xml',
|
||||||
|
],
|
||||||
|
'assets': {
|
||||||
|
'web.assets_backend': [
|
||||||
|
'otoolkit_seo_content/static/src/css/seo_content.css',
|
||||||
|
'otoolkit_seo_content/static/src/js/seo_dashboard.js',
|
||||||
|
'otoolkit_seo_content/static/src/xml/seo_dashboard.xml',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'images': ['static/description/cover.png'],
|
||||||
|
'installable': True,
|
||||||
|
'application': False,
|
||||||
|
'auto_install': False,
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import seo_content
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SeoContentController(http.Controller):
|
||||||
|
|
||||||
|
@http.route('/web/seo-content/check-api', type='jsonrpc', auth='user')
|
||||||
|
def check_api_connection(self):
|
||||||
|
"""Check if API connection is configured and working."""
|
||||||
|
try:
|
||||||
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
|
api_key = ICP.get_param('otoolkit_api_key', '')
|
||||||
|
base_url = ICP.get_param('otoolkit.api.endpoint', '')
|
||||||
|
|
||||||
|
if not api_key or not base_url:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': 'API not configured',
|
||||||
|
'configured': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'configured': True,
|
||||||
|
'endpoint': base_url,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception("API check error")
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
@http.route('/web/seo-content/get-templates', type='json', auth='user')
|
||||||
|
def get_templates(self, content_type=None):
|
||||||
|
"""Get available templates for a content type."""
|
||||||
|
domain = [('active', '=', True)]
|
||||||
|
if content_type:
|
||||||
|
domain.append(('content_type', '=', content_type))
|
||||||
|
|
||||||
|
templates = request.env['otk.seo.template'].search_read(
|
||||||
|
domain,
|
||||||
|
['name', 'content_type', 'description', 'default_tone',
|
||||||
|
'default_word_count', 'include_images', 'default_image_count'],
|
||||||
|
order='sequence, name'
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'templates': templates,
|
||||||
|
}
|
||||||
|
|
||||||
|
@http.route('/web/seo-content/get-languages', type='jsonrpc', auth='user')
|
||||||
|
def get_languages(self):
|
||||||
|
"""Get available languages for content generation."""
|
||||||
|
languages = request.env['res.lang'].search_read(
|
||||||
|
[('active', '=', True)],
|
||||||
|
['code', 'name', 'iso_code'],
|
||||||
|
order='name'
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'languages': languages,
|
||||||
|
}
|
||||||
|
|
||||||
|
@http.route('/web/seo-content/get-blogs', type='jsonrpc', auth='user')
|
||||||
|
def get_blogs(self):
|
||||||
|
"""Get available blogs for publishing."""
|
||||||
|
try:
|
||||||
|
blogs = request.env['blog.blog'].search_read(
|
||||||
|
[('active', '=', True)],
|
||||||
|
['name', 'subtitle'],
|
||||||
|
order='name'
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'blogs': blogs,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
# website_blog not installed
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'blogs': [],
|
||||||
|
'warning': 'Website Blog module not installed',
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
|
||||||
|
<!-- Professional Corporate Voice -->
|
||||||
|
<record id="brand_voice_professional" model="otk.seo.brand.voice">
|
||||||
|
<field name="name">Professional Corporate</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="is_default">True</field>
|
||||||
|
<field name="voice_tone">formal</field>
|
||||||
|
<field name="writing_style">factual</field>
|
||||||
|
<field name="voice_personality">professional, trustworthy, reliable, authoritative</field>
|
||||||
|
<field name="brand_description">A professional corporate brand that values expertise, reliability, and trust. We communicate with clarity and purpose, always maintaining a polished and business-appropriate tone.</field>
|
||||||
|
<field name="target_audience">Business professionals, decision-makers, and corporate clients who value expertise and professionalism.</field>
|
||||||
|
<field name="preferred_words">excellence
|
||||||
|
innovative solutions
|
||||||
|
strategic
|
||||||
|
optimize
|
||||||
|
enterprise-grade
|
||||||
|
proven results
|
||||||
|
industry-leading
|
||||||
|
streamlined
|
||||||
|
efficient
|
||||||
|
comprehensive</field>
|
||||||
|
<field name="avoided_words">cheap
|
||||||
|
basic
|
||||||
|
simple
|
||||||
|
just
|
||||||
|
maybe
|
||||||
|
kind of
|
||||||
|
stuff
|
||||||
|
things
|
||||||
|
awesome
|
||||||
|
cool</field>
|
||||||
|
<field name="custom_instructions">Always maintain a professional tone. Use data and evidence to support claims. Avoid hyperbole and stick to factual statements. Include clear calls-to-action that are business-appropriate.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Friendly Startup Voice -->
|
||||||
|
<record id="brand_voice_friendly" model="otk.seo.brand.voice">
|
||||||
|
<field name="name">Friendly Startup</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="voice_tone">friendly</field>
|
||||||
|
<field name="writing_style">conversational</field>
|
||||||
|
<field name="voice_personality">approachable, innovative, energetic, helpful</field>
|
||||||
|
<field name="brand_description">A modern startup brand that's approachable and human. We believe in making complex things simple and building genuine connections with our users.</field>
|
||||||
|
<field name="target_audience">Tech-savvy professionals, early adopters, and small business owners who appreciate innovation and simplicity.</field>
|
||||||
|
<field name="preferred_words">easy
|
||||||
|
simple
|
||||||
|
powerful
|
||||||
|
love
|
||||||
|
excited
|
||||||
|
amazing
|
||||||
|
game-changer
|
||||||
|
seamless
|
||||||
|
intuitive
|
||||||
|
user-friendly</field>
|
||||||
|
<field name="avoided_words">synergy
|
||||||
|
leverage
|
||||||
|
circle back
|
||||||
|
low-hanging fruit
|
||||||
|
move the needle
|
||||||
|
stakeholder
|
||||||
|
deliverables
|
||||||
|
bandwidth
|
||||||
|
enterprise
|
||||||
|
legacy</field>
|
||||||
|
<field name="custom_instructions">Write like you're talking to a friend who's smart but busy. Use contractions freely. Be enthusiastic but genuine. Break up long sentences. Use examples and analogies to explain complex concepts.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Technical Expert Voice -->
|
||||||
|
<record id="brand_voice_technical" model="otk.seo.brand.voice">
|
||||||
|
<field name="name">Technical Expert</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
<field name="voice_tone">authoritative</field>
|
||||||
|
<field name="writing_style">detailed</field>
|
||||||
|
<field name="voice_personality">knowledgeable, precise, analytical, educational</field>
|
||||||
|
<field name="brand_description">A technical authority that provides in-depth, accurate information. We value precision and thoroughness, helping readers understand complex topics with clarity.</field>
|
||||||
|
<field name="target_audience">Developers, engineers, technical professionals, and anyone seeking detailed, accurate technical information.</field>
|
||||||
|
<field name="preferred_words">implementation
|
||||||
|
architecture
|
||||||
|
scalable
|
||||||
|
robust
|
||||||
|
optimize
|
||||||
|
configure
|
||||||
|
integrate
|
||||||
|
deploy
|
||||||
|
performance
|
||||||
|
documentation</field>
|
||||||
|
<field name="avoided_words">magic
|
||||||
|
automagically
|
||||||
|
simple (when it's not)
|
||||||
|
easy (when it's not)
|
||||||
|
just do
|
||||||
|
obviously
|
||||||
|
trivial
|
||||||
|
basic</field>
|
||||||
|
<field name="industry_terms">API (Application Programming Interface)
|
||||||
|
SDK (Software Development Kit)
|
||||||
|
REST/RESTful
|
||||||
|
JSON
|
||||||
|
CI/CD
|
||||||
|
DevOps</field>
|
||||||
|
<field name="custom_instructions">Be precise with technical terminology. Include code examples or specifications when relevant. Acknowledge complexity rather than oversimplifying. Provide step-by-step instructions when explaining processes. Always consider edge cases.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- E-commerce Sales Voice -->
|
||||||
|
<record id="brand_voice_ecommerce" model="otk.seo.brand.voice">
|
||||||
|
<field name="name">E-commerce Sales</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
<field name="voice_tone">conversational</field>
|
||||||
|
<field name="writing_style">persuasive</field>
|
||||||
|
<field name="voice_personality">enthusiastic, helpful, trustworthy, persuasive</field>
|
||||||
|
<field name="brand_description">An e-commerce brand focused on helping customers find the perfect products. We balance being persuasive with being genuinely helpful, building trust through honest recommendations.</field>
|
||||||
|
<field name="target_audience">Online shoppers looking for quality products, good value, and a trustworthy shopping experience.</field>
|
||||||
|
<field name="preferred_words">discover
|
||||||
|
perfect for
|
||||||
|
bestselling
|
||||||
|
premium quality
|
||||||
|
free shipping
|
||||||
|
limited time
|
||||||
|
customer favorite
|
||||||
|
handpicked
|
||||||
|
exclusive
|
||||||
|
save</field>
|
||||||
|
<field name="avoided_words">cheap
|
||||||
|
bargain bin
|
||||||
|
knockoff
|
||||||
|
discount (overuse)
|
||||||
|
clearance (overuse)
|
||||||
|
must buy
|
||||||
|
you need this
|
||||||
|
act now</field>
|
||||||
|
<field name="custom_instructions">Focus on benefits over features. Use social proof (bestseller, customer favorite) naturally. Create urgency without being pushy. Highlight value propositions clearly. Always include relevant product details like materials, dimensions, or care instructions.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Educational Informative Voice -->
|
||||||
|
<record id="brand_voice_educational" model="otk.seo.brand.voice">
|
||||||
|
<field name="name">Educational Guide</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
<field name="voice_tone">educational</field>
|
||||||
|
<field name="writing_style">detailed</field>
|
||||||
|
<field name="voice_personality">patient, knowledgeable, encouraging, clear</field>
|
||||||
|
<field name="brand_description">An educational brand dedicated to helping readers learn and grow. We believe everyone can understand complex topics when they're explained clearly and patiently.</field>
|
||||||
|
<field name="target_audience">Learners of all levels, from beginners to advanced, who want to understand topics thoroughly and apply their knowledge.</field>
|
||||||
|
<field name="preferred_words">learn
|
||||||
|
understand
|
||||||
|
discover
|
||||||
|
explore
|
||||||
|
guide
|
||||||
|
step-by-step
|
||||||
|
example
|
||||||
|
practice
|
||||||
|
master
|
||||||
|
fundamentals</field>
|
||||||
|
<field name="avoided_words">obviously
|
||||||
|
everyone knows
|
||||||
|
simply
|
||||||
|
just
|
||||||
|
easy (when starting)
|
||||||
|
basic (dismissively)
|
||||||
|
dumb
|
||||||
|
stupid</field>
|
||||||
|
<field name="custom_instructions">Start with foundational concepts before advancing. Use analogies and real-world examples. Break complex topics into digestible sections. Include practical exercises or examples. Encourage the reader and acknowledge that learning takes time. Define jargon when first introduced.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Luxury Premium Voice -->
|
||||||
|
<record id="brand_voice_luxury" model="otk.seo.brand.voice">
|
||||||
|
<field name="name">Luxury Premium</field>
|
||||||
|
<field name="sequence">60</field>
|
||||||
|
<field name="voice_tone">formal</field>
|
||||||
|
<field name="writing_style">storytelling</field>
|
||||||
|
<field name="voice_personality">sophisticated, exclusive, refined, elegant</field>
|
||||||
|
<field name="brand_description">A luxury brand that embodies sophistication and excellence. Every word should reflect the premium quality and exclusive nature of our offerings, creating an aspirational experience.</field>
|
||||||
|
<field name="target_audience">Discerning customers who appreciate quality, craftsmanship, and exclusive experiences. They value heritage, attention to detail, and timeless elegance.</field>
|
||||||
|
<field name="preferred_words">exquisite
|
||||||
|
artisan
|
||||||
|
heritage
|
||||||
|
curated
|
||||||
|
bespoke
|
||||||
|
timeless
|
||||||
|
craftsmanship
|
||||||
|
exclusive
|
||||||
|
refined
|
||||||
|
exceptional</field>
|
||||||
|
<field name="avoided_words">cheap
|
||||||
|
affordable
|
||||||
|
budget
|
||||||
|
discount
|
||||||
|
deal
|
||||||
|
bargain
|
||||||
|
basic
|
||||||
|
standard
|
||||||
|
mass-produced
|
||||||
|
generic</field>
|
||||||
|
<field name="custom_instructions">Evoke emotion and aspiration. Focus on craftsmanship, heritage, and the story behind products. Use sensory language to describe experiences. Maintain an air of exclusivity without being pretentious. Quality over quantity in descriptions.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- Cron job to submit pending images for generation -->
|
||||||
|
<record id="ir_cron_submit_pending_images" model="ir.cron">
|
||||||
|
<field name="name">SEO Content: Submit Pending Images</field>
|
||||||
|
<field name="model_id" ref="model_otk_seo_content_image"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model.cron_submit_pending_images()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
<field name="priority">10</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Cron job to poll image generation status -->
|
||||||
|
<record id="ir_cron_poll_image_status" model="ir.cron">
|
||||||
|
<field name="name">SEO Content: Poll Image Status</field>
|
||||||
|
<field name="model_id" ref="model_otk_seo_content_image"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model.cron_poll_image_status()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
<field name="priority">5</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Cron job to poll content generation status (async) -->
|
||||||
|
<record id="ir_cron_poll_content_status" model="ir.cron">
|
||||||
|
<field name="name">SEO Content: Poll Content Status</field>
|
||||||
|
<field name="model_id" ref="model_otk_seo_content"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model.cron_poll_content_status()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
<field name="priority">4</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Cron job to check for stale content and send alerts -->
|
||||||
|
<record id="ir_cron_check_stale_content" model="ir.cron">
|
||||||
|
<field name="name">SEO Content: Check Stale Content</field>
|
||||||
|
<field name="model_id" ref="model_otk_seo_content"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model.cron_check_stale_content()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">days</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
<field name="priority">50</field>
|
||||||
|
<field name="nextcall" eval="(DateTime.now() + timedelta(days=1)).strftime('%Y-%m-%d 06:00:00')"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Cron job to process batch items asynchronously -->
|
||||||
|
<record id="ir_cron_process_batch_items" model="ir.cron">
|
||||||
|
<field name="name">SEO Content: Process Batch Items</field>
|
||||||
|
<field name="model_id" ref="model_otk_seo_content_batch"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model.cron_process_batch_items()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
<field name="priority">15</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Cron job to process scheduled content ideas -->
|
||||||
|
<record id="ir_cron_process_scheduled_ideas" model="ir.cron">
|
||||||
|
<field name="name">SEO Content: Process Scheduled Ideas</field>
|
||||||
|
<field name="model_id" ref="model_otk_seo_content_idea"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model.cron_process_scheduled_ideas()</field>
|
||||||
|
<field name="interval_number">30</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
<field name="priority">20</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Cron job to check generating ideas completion -->
|
||||||
|
<record id="ir_cron_check_generating_ideas" model="ir.cron">
|
||||||
|
<field name="name">SEO Content: Check Generating Ideas</field>
|
||||||
|
<field name="model_id" ref="model_otk_seo_content_idea"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model.cron_check_generating_ideas()</field>
|
||||||
|
<field name="interval_number">2</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
<field name="priority">6</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
<record model="ir.module.category" id="module_category_otoolkit_seo">
|
||||||
|
<field name="name">O'Toolkit SEO</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
|
||||||
|
<!-- How-To Guide Template -->
|
||||||
|
<record id="seo_template_how_to" model="otk.seo.template">
|
||||||
|
<field name="name">How-To Guide</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="content_type">blog_post</field>
|
||||||
|
<field name="description">Step-by-step instructional content that teaches readers how to accomplish a specific task or goal.</field>
|
||||||
|
<field name="default_tone">professional</field>
|
||||||
|
<field name="default_word_count">medium</field>
|
||||||
|
<field name="include_images">True</field>
|
||||||
|
<field name="default_image_count">2</field>
|
||||||
|
<field name="default_image_style">photorealistic</field>
|
||||||
|
<field name="include_toc">True</field>
|
||||||
|
<field name="include_faq">False</field>
|
||||||
|
<field name="include_cta">True</field>
|
||||||
|
<field name="header_structure">h2_h3</field>
|
||||||
|
<field name="system_prompt">You are an expert technical writer creating clear, actionable how-to guides.
|
||||||
|
Write in a helpful, instructive tone. Use numbered steps for processes.
|
||||||
|
Include practical tips and common pitfalls to avoid.
|
||||||
|
Make content scannable with clear headers and bullet points.</field>
|
||||||
|
<field name="content_prompt_template">Write a comprehensive how-to guide about: {topic}
|
||||||
|
|
||||||
|
Target keywords: {keywords}
|
||||||
|
Target word count: {word_count} words
|
||||||
|
Tone: {tone}
|
||||||
|
Language: {language}
|
||||||
|
|
||||||
|
Structure the content with:
|
||||||
|
1. Brief introduction explaining what readers will learn
|
||||||
|
2. Prerequisites or requirements (if applicable)
|
||||||
|
3. Step-by-step instructions with clear headers
|
||||||
|
4. Tips and best practices
|
||||||
|
5. Conclusion with next steps</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Listicle Template -->
|
||||||
|
<record id="seo_template_listicle" model="otk.seo.template">
|
||||||
|
<field name="name">Listicle (Top X...)</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="content_type">blog_post</field>
|
||||||
|
<field name="description">Numbered list format articles like "Top 10...", "5 Best...", etc. Great for engagement and social sharing.</field>
|
||||||
|
<field name="default_tone">conversational</field>
|
||||||
|
<field name="default_word_count">medium</field>
|
||||||
|
<field name="include_images">True</field>
|
||||||
|
<field name="default_image_count">1</field>
|
||||||
|
<field name="default_image_style">digital_art</field>
|
||||||
|
<field name="include_toc">True</field>
|
||||||
|
<field name="include_faq">False</field>
|
||||||
|
<field name="include_cta">True</field>
|
||||||
|
<field name="header_structure">h2_only</field>
|
||||||
|
<field name="system_prompt">You are a content creator writing engaging listicle articles.
|
||||||
|
Make each list item valuable and actionable.
|
||||||
|
Use engaging headlines for each item.
|
||||||
|
Include brief explanations for each point.
|
||||||
|
Keep the tone engaging and easy to read.</field>
|
||||||
|
<field name="content_prompt_template">Write a listicle article about: {topic}
|
||||||
|
|
||||||
|
Target keywords: {keywords}
|
||||||
|
Target word count: {word_count} words
|
||||||
|
Tone: {tone}
|
||||||
|
Language: {language}
|
||||||
|
|
||||||
|
Structure:
|
||||||
|
1. Engaging introduction with a hook
|
||||||
|
2. Numbered list items (5-10 items) with H2 headers
|
||||||
|
3. Each item should have a brief explanation (50-100 words)
|
||||||
|
4. Conclusion summarizing key takeaways</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Product Comparison Template -->
|
||||||
|
<record id="seo_template_comparison" model="otk.seo.template">
|
||||||
|
<field name="name">Product Comparison</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
<field name="content_type">blog_post</field>
|
||||||
|
<field name="description">Side-by-side comparison content for products, services, or solutions.</field>
|
||||||
|
<field name="default_tone">professional</field>
|
||||||
|
<field name="default_word_count">long</field>
|
||||||
|
<field name="include_images">True</field>
|
||||||
|
<field name="default_image_count">1</field>
|
||||||
|
<field name="default_image_style">illustration</field>
|
||||||
|
<field name="include_toc">True</field>
|
||||||
|
<field name="include_faq">True</field>
|
||||||
|
<field name="include_cta">True</field>
|
||||||
|
<field name="header_structure">h2_h3</field>
|
||||||
|
<field name="system_prompt">You are an expert analyst creating objective product comparisons.
|
||||||
|
Be balanced and fair in your comparisons.
|
||||||
|
Include specific features, pros, and cons.
|
||||||
|
Help readers make informed decisions.
|
||||||
|
Use tables or structured comparisons where appropriate.</field>
|
||||||
|
<field name="content_prompt_template">Write a detailed comparison article about: {topic}
|
||||||
|
|
||||||
|
Target keywords: {keywords}
|
||||||
|
Target word count: {word_count} words
|
||||||
|
Tone: {tone}
|
||||||
|
Language: {language}
|
||||||
|
|
||||||
|
Structure:
|
||||||
|
1. Introduction explaining the comparison criteria
|
||||||
|
2. Overview of each option
|
||||||
|
3. Feature-by-feature comparison
|
||||||
|
4. Pros and cons for each
|
||||||
|
5. Recommendation based on different use cases
|
||||||
|
6. FAQ section addressing common questions</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Industry News Template -->
|
||||||
|
<record id="seo_template_news" model="otk.seo.template">
|
||||||
|
<field name="name">Industry News/Trends</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
<field name="content_type">blog_post</field>
|
||||||
|
<field name="description">Thought leadership content about industry trends, news analysis, and expert insights.</field>
|
||||||
|
<field name="default_tone">professional</field>
|
||||||
|
<field name="default_word_count">medium</field>
|
||||||
|
<field name="include_images">True</field>
|
||||||
|
<field name="default_image_count">1</field>
|
||||||
|
<field name="default_image_style">photorealistic</field>
|
||||||
|
<field name="include_toc">False</field>
|
||||||
|
<field name="include_faq">False</field>
|
||||||
|
<field name="include_cta">True</field>
|
||||||
|
<field name="header_structure">h2_h3</field>
|
||||||
|
<field name="system_prompt">You are an industry expert writing insightful analysis.
|
||||||
|
Provide context and expert perspective.
|
||||||
|
Back up claims with reasoning.
|
||||||
|
Make content relevant and timely.
|
||||||
|
Maintain a professional, authoritative tone.</field>
|
||||||
|
<field name="content_prompt_template">Write an industry analysis/trends article about: {topic}
|
||||||
|
|
||||||
|
Target keywords: {keywords}
|
||||||
|
Target word count: {word_count} words
|
||||||
|
Tone: {tone}
|
||||||
|
Language: {language}
|
||||||
|
|
||||||
|
Structure:
|
||||||
|
1. Hook introducing the trend or news
|
||||||
|
2. Background and context
|
||||||
|
3. Analysis of implications
|
||||||
|
4. Expert insights and predictions
|
||||||
|
5. What this means for the reader
|
||||||
|
6. Call to action</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Case Study Template -->
|
||||||
|
<record id="seo_template_case_study" model="otk.seo.template">
|
||||||
|
<field name="name">Case Study</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
<field name="content_type">blog_post</field>
|
||||||
|
<field name="description">Success story format showcasing real-world results and implementations.</field>
|
||||||
|
<field name="default_tone">professional</field>
|
||||||
|
<field name="default_word_count">long</field>
|
||||||
|
<field name="include_images">True</field>
|
||||||
|
<field name="default_image_count">2</field>
|
||||||
|
<field name="default_image_style">photorealistic</field>
|
||||||
|
<field name="include_toc">True</field>
|
||||||
|
<field name="include_faq">False</field>
|
||||||
|
<field name="include_cta">True</field>
|
||||||
|
<field name="header_structure">h2_h3</field>
|
||||||
|
<field name="system_prompt">You are a content writer creating compelling case studies.
|
||||||
|
Follow the problem-solution-results format.
|
||||||
|
Include specific metrics and outcomes where possible.
|
||||||
|
Make the story relatable and inspiring.
|
||||||
|
Focus on the transformation and value delivered.</field>
|
||||||
|
<field name="content_prompt_template">Write a case study article about: {topic}
|
||||||
|
|
||||||
|
Target keywords: {keywords}
|
||||||
|
Target word count: {word_count} words
|
||||||
|
Tone: {tone}
|
||||||
|
Language: {language}
|
||||||
|
|
||||||
|
Structure:
|
||||||
|
1. Executive summary / Key results
|
||||||
|
2. The Challenge - What problem needed solving
|
||||||
|
3. The Solution - How it was addressed
|
||||||
|
4. Implementation - Steps taken
|
||||||
|
5. Results - Measurable outcomes
|
||||||
|
6. Lessons learned
|
||||||
|
7. Call to action</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- FAQ Article Template -->
|
||||||
|
<record id="seo_template_faq" model="otk.seo.template">
|
||||||
|
<field name="name">FAQ Article</field>
|
||||||
|
<field name="sequence">60</field>
|
||||||
|
<field name="content_type">blog_post</field>
|
||||||
|
<field name="description">Question-and-answer format content addressing common queries about a topic.</field>
|
||||||
|
<field name="default_tone">conversational</field>
|
||||||
|
<field name="default_word_count">medium</field>
|
||||||
|
<field name="include_images">False</field>
|
||||||
|
<field name="default_image_count">0</field>
|
||||||
|
<field name="default_image_style">illustration</field>
|
||||||
|
<field name="include_toc">True</field>
|
||||||
|
<field name="include_faq">False</field>
|
||||||
|
<field name="include_cta">True</field>
|
||||||
|
<field name="header_structure">h2_only</field>
|
||||||
|
<field name="system_prompt">You are a helpful expert answering frequently asked questions.
|
||||||
|
Address each question thoroughly but concisely.
|
||||||
|
Use clear, accessible language.
|
||||||
|
Anticipate follow-up questions.
|
||||||
|
Make answers actionable where possible.</field>
|
||||||
|
<field name="content_prompt_template">Write an FAQ article about: {topic}
|
||||||
|
|
||||||
|
Target keywords: {keywords}
|
||||||
|
Target word count: {word_count} words
|
||||||
|
Tone: {tone}
|
||||||
|
Language: {language}
|
||||||
|
|
||||||
|
Structure:
|
||||||
|
1. Brief introduction about the topic
|
||||||
|
2. 8-12 frequently asked questions with detailed answers
|
||||||
|
3. Each question as an H2 header
|
||||||
|
4. Answers should be 50-150 words each
|
||||||
|
5. Conclusion with additional resources or contact info</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Product Description Template -->
|
||||||
|
<record id="seo_template_product_desc" model="otk.seo.template">
|
||||||
|
<field name="name">Product Description</field>
|
||||||
|
<field name="sequence">70</field>
|
||||||
|
<field name="content_type">product_desc</field>
|
||||||
|
<field name="description">Compelling product descriptions that highlight features, benefits, and value propositions.</field>
|
||||||
|
<field name="default_tone">persuasive</field>
|
||||||
|
<field name="default_word_count">short</field>
|
||||||
|
<field name="include_images">False</field>
|
||||||
|
<field name="default_image_count">0</field>
|
||||||
|
<field name="default_image_style">photorealistic</field>
|
||||||
|
<field name="include_toc">False</field>
|
||||||
|
<field name="include_faq">False</field>
|
||||||
|
<field name="include_cta">True</field>
|
||||||
|
<field name="header_structure">h2_only</field>
|
||||||
|
<field name="system_prompt">You are an expert copywriter creating compelling product descriptions.
|
||||||
|
Focus on benefits, not just features.
|
||||||
|
Use sensory and emotional language.
|
||||||
|
Address customer pain points.
|
||||||
|
Include a clear call to action.
|
||||||
|
Make content scannable with bullet points for features.</field>
|
||||||
|
<field name="content_prompt_template">Write a compelling product description for: {topic}
|
||||||
|
|
||||||
|
Product details: {product_name}
|
||||||
|
Target keywords: {keywords}
|
||||||
|
Tone: {tone}
|
||||||
|
Language: {language}
|
||||||
|
|
||||||
|
Structure:
|
||||||
|
1. Attention-grabbing opening line
|
||||||
|
2. Key benefits (what problems it solves)
|
||||||
|
3. Main features as bullet points
|
||||||
|
4. Social proof or trust elements
|
||||||
|
5. Clear call to action</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
|
||||||
|
<!-- Translation Config for SEO Content -->
|
||||||
|
<record id="translation_config_seo_content" model="otk.translation.config">
|
||||||
|
<field name="model_id" ref="model_otk_seo_content"/>
|
||||||
|
<field name="active">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Translation Config for SEO Template -->
|
||||||
|
<record id="translation_config_seo_template" model="otk.translation.config">
|
||||||
|
<field name="model_id" ref="model_otk_seo_template"/>
|
||||||
|
<field name="active">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Translation Config for Brand Voice -->
|
||||||
|
<record id="translation_config_brand_voice" model="otk.translation.config">
|
||||||
|
<field name="model_id" ref="model_otk_seo_brand_voice"/>
|
||||||
|
<field name="active">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from . import brand_voice
|
||||||
|
from . import seo_content
|
||||||
|
from . import seo_content_batch
|
||||||
|
from . import seo_content_idea
|
||||||
|
from . import seo_content_image
|
||||||
|
from . import seo_reference
|
||||||
|
from . import seo_template
|
||||||
|
from . import res_config_settings
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
class OtkSeoBrandVoice(models.Model):
|
||||||
|
_name = 'otk.seo.brand.voice'
|
||||||
|
_description = 'Brand Voice Guidelines'
|
||||||
|
_order = 'sequence, name'
|
||||||
|
|
||||||
|
name = fields.Char('Brand Name', required=True, translate=True,
|
||||||
|
help='Name of the brand or voice profile (e.g., "Company Main Brand", "Product Line X")')
|
||||||
|
|
||||||
|
sequence = fields.Integer('Sequence', default=10)
|
||||||
|
active = fields.Boolean('Active', default=True)
|
||||||
|
is_default = fields.Boolean('Default Voice',
|
||||||
|
help='If enabled, this voice will be automatically selected for new content.')
|
||||||
|
|
||||||
|
# === Brand Identity ===
|
||||||
|
brand_description = fields.Text('Brand Description', translate=True,
|
||||||
|
help='Brief description of the brand, its values, and target audience. '
|
||||||
|
'Example: "We are a modern tech company targeting young professionals..."')
|
||||||
|
|
||||||
|
target_audience = fields.Text('Target Audience', translate=True,
|
||||||
|
help='Describe the ideal reader/customer. '
|
||||||
|
'Example: "Small business owners aged 30-50, tech-savvy but time-constrained..."')
|
||||||
|
|
||||||
|
# === Voice Characteristics ===
|
||||||
|
voice_tone = fields.Selection([
|
||||||
|
('formal', 'Formal & Professional'),
|
||||||
|
('friendly', 'Friendly & Approachable'),
|
||||||
|
('authoritative', 'Authoritative & Expert'),
|
||||||
|
('conversational', 'Conversational & Casual'),
|
||||||
|
('inspirational', 'Inspirational & Motivating'),
|
||||||
|
('educational', 'Educational & Informative'),
|
||||||
|
('playful', 'Playful & Fun'),
|
||||||
|
('empathetic', 'Empathetic & Supportive'),
|
||||||
|
], string='Voice Tone', default='friendly',
|
||||||
|
help='The overall tone of voice to use in content.')
|
||||||
|
|
||||||
|
voice_personality = fields.Char('Personality Traits',
|
||||||
|
help='Comma-separated personality traits. '
|
||||||
|
'Example: "innovative, trustworthy, approachable, knowledgeable"')
|
||||||
|
|
||||||
|
writing_style = fields.Selection([
|
||||||
|
('concise', 'Concise & Direct'),
|
||||||
|
('detailed', 'Detailed & Thorough'),
|
||||||
|
('storytelling', 'Storytelling & Narrative'),
|
||||||
|
('factual', 'Factual & Data-driven'),
|
||||||
|
('persuasive', 'Persuasive & Sales-oriented'),
|
||||||
|
('conversational', 'Conversational & Casual'),
|
||||||
|
], string='Writing Style', default='concise',
|
||||||
|
help='The preferred writing style for content.')
|
||||||
|
|
||||||
|
# === Vocabulary Guidelines ===
|
||||||
|
preferred_words = fields.Text('Preferred Words & Phrases', translate=True,
|
||||||
|
help='Words and phrases to use frequently. One per line. '
|
||||||
|
'Example:\ninnovative\ncutting-edge\nseamless experience')
|
||||||
|
|
||||||
|
avoided_words = fields.Text('Words to Avoid', translate=True,
|
||||||
|
help='Words and phrases to never use. One per line. '
|
||||||
|
'Example:\ncheap\nbasic\njust')
|
||||||
|
|
||||||
|
industry_terms = fields.Text('Industry Terminology', translate=True,
|
||||||
|
help='Industry-specific terms and their preferred usage. '
|
||||||
|
'Example:\nSaaS (not "software as a service")\nAI-powered (not "artificial intelligence powered")')
|
||||||
|
|
||||||
|
# === Custom Instructions ===
|
||||||
|
custom_instructions = fields.Text('Custom AI Instructions', translate=True,
|
||||||
|
help='Additional instructions for the AI when generating content. '
|
||||||
|
'Example: "Always include a call-to-action at the end. '
|
||||||
|
'Reference sustainability when relevant. Never make claims without evidence."')
|
||||||
|
|
||||||
|
# === Examples ===
|
||||||
|
example_good = fields.Text('Example of Good Content',
|
||||||
|
help='Paste an example of content that represents your brand voice well.')
|
||||||
|
|
||||||
|
example_bad = fields.Text('Example of What to Avoid',
|
||||||
|
help='Paste an example of content that does NOT match your brand voice.')
|
||||||
|
|
||||||
|
# === Usage Tracking ===
|
||||||
|
content_count = fields.Integer('Content Count', compute='_compute_content_count',
|
||||||
|
help='Number of content pieces using this brand voice.')
|
||||||
|
|
||||||
|
@api.depends('name')
|
||||||
|
def _compute_content_count(self):
|
||||||
|
if not self.ids:
|
||||||
|
return
|
||||||
|
data = self.env['otk.seo.content']._read_group(
|
||||||
|
[('brand_voice_id', 'in', self.ids)],
|
||||||
|
['brand_voice_id'],
|
||||||
|
['__count'],
|
||||||
|
)
|
||||||
|
counts = {voice.id: count for voice, count in data}
|
||||||
|
for record in self:
|
||||||
|
record.content_count = counts.get(record.id, 0)
|
||||||
|
|
||||||
|
@api.constrains('is_default')
|
||||||
|
def _check_single_default(self):
|
||||||
|
"""Ensure only one brand voice is set as default."""
|
||||||
|
for record in self:
|
||||||
|
if record.is_default:
|
||||||
|
existing_default = self.search([
|
||||||
|
('is_default', '=', True),
|
||||||
|
('id', '!=', record.id),
|
||||||
|
('active', '=', True)
|
||||||
|
])
|
||||||
|
if existing_default:
|
||||||
|
existing_default.write({'is_default': False})
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_default_voice(self):
|
||||||
|
"""Get the default brand voice, if any."""
|
||||||
|
return self.search([('is_default', '=', True), ('active', '=', True)], limit=1)
|
||||||
|
|
||||||
|
def get_prompt_instructions(self):
|
||||||
|
"""Generate AI instructions from brand voice settings."""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
instructions = []
|
||||||
|
|
||||||
|
# Brand identity
|
||||||
|
if self.brand_description:
|
||||||
|
instructions.append(f"Brand Context: {self.brand_description}")
|
||||||
|
|
||||||
|
if self.target_audience:
|
||||||
|
instructions.append(f"Target Audience: {self.target_audience}")
|
||||||
|
|
||||||
|
# Voice characteristics
|
||||||
|
tone_descriptions = {
|
||||||
|
'formal': 'Use a formal, professional tone. Avoid slang and casual expressions.',
|
||||||
|
'friendly': 'Use a friendly, approachable tone. Be warm but professional.',
|
||||||
|
'authoritative': 'Write with authority and expertise. Be confident and assertive.',
|
||||||
|
'conversational': 'Write in a conversational, casual tone. Use contractions and relatable language.',
|
||||||
|
'inspirational': 'Write in an inspirational, motivating tone. Use uplifting language.',
|
||||||
|
'educational': 'Write in an educational, informative tone. Explain concepts clearly.',
|
||||||
|
'playful': 'Write in a playful, fun tone. Use humor where appropriate.',
|
||||||
|
'empathetic': 'Write with empathy and understanding. Acknowledge reader challenges.',
|
||||||
|
}
|
||||||
|
if self.voice_tone:
|
||||||
|
instructions.append(f"Voice Tone: {tone_descriptions.get(self.voice_tone, self.voice_tone)}")
|
||||||
|
|
||||||
|
if self.voice_personality:
|
||||||
|
instructions.append(f"Personality Traits: The content should feel {self.voice_personality}.")
|
||||||
|
|
||||||
|
style_descriptions = {
|
||||||
|
'concise': 'Keep writing concise and direct. Avoid unnecessary words.',
|
||||||
|
'detailed': 'Provide detailed, thorough explanations. Be comprehensive.',
|
||||||
|
'storytelling': 'Use storytelling and narrative techniques. Engage emotionally.',
|
||||||
|
'factual': 'Focus on facts and data. Support claims with evidence.',
|
||||||
|
'persuasive': 'Write persuasively. Focus on benefits and calls-to-action.',
|
||||||
|
}
|
||||||
|
if self.writing_style:
|
||||||
|
instructions.append(f"Writing Style: {style_descriptions.get(self.writing_style, self.writing_style)}")
|
||||||
|
|
||||||
|
# Vocabulary
|
||||||
|
if self.preferred_words:
|
||||||
|
words = [w.strip() for w in self.preferred_words.split('\n') if w.strip()]
|
||||||
|
if words:
|
||||||
|
instructions.append(f"Preferred vocabulary (use these when appropriate): {', '.join(words[:15])}")
|
||||||
|
|
||||||
|
if self.avoided_words:
|
||||||
|
words = [w.strip() for w in self.avoided_words.split('\n') if w.strip()]
|
||||||
|
if words:
|
||||||
|
instructions.append(f"Words to AVOID: {', '.join(words[:15])}")
|
||||||
|
|
||||||
|
if self.industry_terms:
|
||||||
|
instructions.append(f"Industry terminology: {self.industry_terms}")
|
||||||
|
|
||||||
|
# Custom instructions
|
||||||
|
if self.custom_instructions:
|
||||||
|
instructions.append(f"Additional guidelines: {self.custom_instructions}")
|
||||||
|
|
||||||
|
# Examples
|
||||||
|
if self.example_good:
|
||||||
|
instructions.append(f"Example of desired content style:\n{self.example_good[:500]}")
|
||||||
|
|
||||||
|
return '\n\n'.join(instructions)
|
||||||
|
|
||||||
|
def action_view_content(self):
|
||||||
|
"""View content using this brand voice."""
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _('Content with %s Voice') % self.name,
|
||||||
|
'res_model': 'otk.seo.content',
|
||||||
|
'view_mode': 'list,form',
|
||||||
|
'domain': [('brand_voice_id', '=', self.id)],
|
||||||
|
'context': {'default_brand_voice_id': self.id},
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class ResConfigSettings(models.TransientModel):
|
||||||
|
_inherit = 'res.config.settings'
|
||||||
|
|
||||||
|
# === SEO Content Settings ===
|
||||||
|
seo_default_blog_id = fields.Many2one(
|
||||||
|
'blog.blog',
|
||||||
|
string='Default Blog',
|
||||||
|
config_parameter='otoolkit_seo_content.default_blog_id',
|
||||||
|
help='The default blog where generated content will be published. Users can override this for individual content items.'
|
||||||
|
)
|
||||||
|
|
||||||
|
seo_default_image_count = fields.Integer(
|
||||||
|
string='Default Image Count',
|
||||||
|
config_parameter='otoolkit_seo_content.default_image_count',
|
||||||
|
default=1,
|
||||||
|
help='Default number of AI-generated images to create per content item. Set to 0 to disable image generation by default.'
|
||||||
|
)
|
||||||
|
|
||||||
|
seo_default_image_quality = fields.Selection([
|
||||||
|
('standard', 'Standard'),
|
||||||
|
('hd', 'HD'),
|
||||||
|
], string='Default Image Quality',
|
||||||
|
config_parameter='otoolkit_seo_content.default_image_quality',
|
||||||
|
default='standard',
|
||||||
|
help='Default quality setting for generated images. HD produces sharper images with more detail but consumes more tokens.'
|
||||||
|
)
|
||||||
|
|
||||||
|
seo_auto_generate_alt_text = fields.Boolean(
|
||||||
|
string='Auto-generate Alt Text',
|
||||||
|
config_parameter='otoolkit_seo_content.auto_generate_alt_text',
|
||||||
|
default=True,
|
||||||
|
help='When enabled, SEO-friendly alt text will be automatically generated for each image based on the content title and context.'
|
||||||
|
)
|
||||||
|
|
||||||
|
seo_max_versions = fields.Integer(
|
||||||
|
string='Max Version History',
|
||||||
|
config_parameter='otoolkit_seo_content.max_versions',
|
||||||
|
default=5,
|
||||||
|
help='Maximum number of content versions to keep in history. When exceeded, the oldest version is automatically deleted. Set to 0 to disable version history.'
|
||||||
|
)
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
import base64
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.exceptions import UserError, ValidationError
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SeoContentBatch(models.Model):
|
||||||
|
_name = 'otk.seo.content.batch'
|
||||||
|
_description = 'SEO Content Batch Job'
|
||||||
|
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||||
|
_order = 'create_date desc'
|
||||||
|
|
||||||
|
name = fields.Char('Batch Name', required=True, default=lambda self: _('New Batch'))
|
||||||
|
state = fields.Selection([
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('queued', 'Queued'),
|
||||||
|
('processing', 'Processing'),
|
||||||
|
('paused', 'Paused'),
|
||||||
|
('done', 'Completed'),
|
||||||
|
('failed', 'Failed'),
|
||||||
|
('cancelled', 'Cancelled'),
|
||||||
|
], string='Status', default='draft', tracking=True)
|
||||||
|
|
||||||
|
# === Concurrency Settings ===
|
||||||
|
concurrency = fields.Integer('Concurrency', default=5,
|
||||||
|
help='Number of items to process per cron run. Higher values process faster but use more resources.')
|
||||||
|
|
||||||
|
@api.constrains('concurrency')
|
||||||
|
def _check_concurrency_range(self):
|
||||||
|
for record in self:
|
||||||
|
if not (1 <= record.concurrency <= 10):
|
||||||
|
raise ValidationError(_('Concurrency must be between 1 and 10.'))
|
||||||
|
|
||||||
|
# === Source Configuration ===
|
||||||
|
source_type = fields.Selection([
|
||||||
|
('products', 'From Products'),
|
||||||
|
('csv', 'From CSV'),
|
||||||
|
('keywords', 'From Keywords List'),
|
||||||
|
], string='Source Type', default='products', required=True)
|
||||||
|
|
||||||
|
product_ids = fields.Many2many('product.template', string='Products',
|
||||||
|
help='Products to generate content for')
|
||||||
|
csv_file = fields.Binary('CSV File', attachment=True)
|
||||||
|
csv_filename = fields.Char('CSV Filename')
|
||||||
|
keywords_list = fields.Text('Keywords List',
|
||||||
|
help='One keyword/topic per line')
|
||||||
|
|
||||||
|
# === Batch Items ===
|
||||||
|
item_ids = fields.One2many('otk.seo.content.batch.item', 'batch_id', string='Batch Items')
|
||||||
|
|
||||||
|
# === Shared Settings ===
|
||||||
|
content_type = fields.Selection([
|
||||||
|
('blog_post', 'Blog Post'),
|
||||||
|
('product_desc', 'Product Description'),
|
||||||
|
('category_desc', 'Category Description'),
|
||||||
|
('landing_page', 'Landing Page Content'),
|
||||||
|
], string='Content Type', default='product_desc', required=True)
|
||||||
|
|
||||||
|
template_id = fields.Many2one('otk.seo.template', 'Content Template',
|
||||||
|
domain="[('content_type', '=', content_type)]")
|
||||||
|
brand_voice_id = fields.Many2one('otk.seo.brand.voice', 'Brand Voice',
|
||||||
|
default=lambda self: self.env['otk.seo.brand.voice'].search([('is_default', '=', True)], limit=1))
|
||||||
|
tone = fields.Selection([
|
||||||
|
('professional', 'Professional'),
|
||||||
|
('casual', 'Casual'),
|
||||||
|
('technical', 'Technical'),
|
||||||
|
('persuasive', 'Persuasive'),
|
||||||
|
('conversational', 'Conversational'),
|
||||||
|
], string='Tone', default='professional')
|
||||||
|
target_word_count = fields.Selection([
|
||||||
|
('short', 'Short (300-500 words)'),
|
||||||
|
('medium', 'Medium (500-1000 words)'),
|
||||||
|
('long', 'Long (1000-2000 words)'),
|
||||||
|
], string='Target Length', default='medium')
|
||||||
|
language_id = fields.Many2one('res.lang', 'Language',
|
||||||
|
default=lambda self: self.env['res.lang']._lang_get(self.env.lang or 'en_US'))
|
||||||
|
|
||||||
|
# === Image Settings ===
|
||||||
|
include_images = fields.Boolean('Generate Images', default=True)
|
||||||
|
image_count = fields.Integer('Images per Content', default=1)
|
||||||
|
image_style = fields.Selection([
|
||||||
|
('photorealistic', 'Photorealistic'),
|
||||||
|
('illustration', 'Illustration'),
|
||||||
|
('digital_art', 'Digital Art'),
|
||||||
|
('watercolor', 'Watercolor'),
|
||||||
|
('sketch', 'Sketch'),
|
||||||
|
('3d_render', '3D Render'),
|
||||||
|
], string='Image Style', default='photorealistic')
|
||||||
|
|
||||||
|
# === Blog Settings ===
|
||||||
|
blog_id = fields.Many2one('blog.blog', 'Target Blog',
|
||||||
|
help='The blog where posts will be published. Only used for Blog Post content type.')
|
||||||
|
|
||||||
|
# === Progress Tracking ===
|
||||||
|
total_items = fields.Integer('Total Items', compute='_compute_progress', store=True)
|
||||||
|
completed_items = fields.Integer('Completed', compute='_compute_progress', store=True)
|
||||||
|
failed_items = fields.Integer('Failed', compute='_compute_progress', store=True)
|
||||||
|
progress_percent = fields.Float('Progress %', compute='_compute_progress', store=True)
|
||||||
|
|
||||||
|
# === Generated Content ===
|
||||||
|
content_ids = fields.One2many('otk.seo.content', 'batch_id', string='Generated Content')
|
||||||
|
content_count = fields.Integer('Content Count', compute='_compute_content_count')
|
||||||
|
|
||||||
|
# === Timing ===
|
||||||
|
started_at = fields.Datetime('Started At')
|
||||||
|
completed_at = fields.Datetime('Completed At')
|
||||||
|
duration = fields.Float('Duration (minutes)', compute='_compute_duration')
|
||||||
|
|
||||||
|
@api.depends('item_ids', 'item_ids.state')
|
||||||
|
def _compute_progress(self):
|
||||||
|
for batch in self:
|
||||||
|
items = batch.item_ids
|
||||||
|
batch.total_items = len(items)
|
||||||
|
batch.completed_items = len(items.filtered(lambda i: i.state == 'done'))
|
||||||
|
batch.failed_items = len(items.filtered(lambda i: i.state == 'failed'))
|
||||||
|
batch.progress_percent = (
|
||||||
|
(batch.completed_items + batch.failed_items) / batch.total_items * 100
|
||||||
|
if batch.total_items else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('content_ids')
|
||||||
|
def _compute_content_count(self):
|
||||||
|
for batch in self:
|
||||||
|
batch.content_count = len(batch.content_ids)
|
||||||
|
|
||||||
|
# === ETA ===
|
||||||
|
eta_minutes = fields.Float('ETA (minutes)', compute='_compute_eta',
|
||||||
|
help='Estimated time remaining for batch completion.')
|
||||||
|
avg_processing_time = fields.Float('Avg Time (s)', compute='_compute_eta',
|
||||||
|
help='Average processing time per item in seconds.')
|
||||||
|
|
||||||
|
@api.depends('started_at', 'completed_at')
|
||||||
|
def _compute_duration(self):
|
||||||
|
for batch in self:
|
||||||
|
if batch.started_at and batch.completed_at:
|
||||||
|
delta = batch.completed_at - batch.started_at
|
||||||
|
batch.duration = delta.total_seconds() / 60
|
||||||
|
else:
|
||||||
|
batch.duration = 0
|
||||||
|
|
||||||
|
@api.depends('started_at', 'completed_items', 'failed_items', 'total_items')
|
||||||
|
def _compute_eta(self):
|
||||||
|
for batch in self:
|
||||||
|
done_count = batch.completed_items + batch.failed_items
|
||||||
|
if batch.started_at and done_count > 0:
|
||||||
|
now = fields.Datetime.now()
|
||||||
|
elapsed = (now - batch.started_at).total_seconds()
|
||||||
|
avg_time = elapsed / done_count
|
||||||
|
remaining = batch.total_items - done_count
|
||||||
|
batch.avg_processing_time = round(avg_time, 1)
|
||||||
|
batch.eta_minutes = round((avg_time * remaining) / 60, 1)
|
||||||
|
else:
|
||||||
|
batch.avg_processing_time = 0
|
||||||
|
batch.eta_minutes = 0
|
||||||
|
|
||||||
|
@api.onchange('source_type')
|
||||||
|
def _onchange_source_type(self):
|
||||||
|
"""Update content type based on source selection."""
|
||||||
|
if self.source_type == 'products':
|
||||||
|
self.content_type = 'product_desc'
|
||||||
|
elif self.source_type == 'keywords':
|
||||||
|
# Keywords can be for any content type, default to blog
|
||||||
|
if self.content_type == 'product_desc':
|
||||||
|
self.content_type = 'blog_post'
|
||||||
|
|
||||||
|
@api.onchange('content_type')
|
||||||
|
def _onchange_content_type(self):
|
||||||
|
"""Reset template when content type changes and update source type."""
|
||||||
|
self.template_id = False
|
||||||
|
# If switching to product_desc, suggest products source
|
||||||
|
if self.content_type == 'product_desc' and self.source_type == 'keywords':
|
||||||
|
self.source_type = 'products'
|
||||||
|
# If switching away from product_desc with products source, switch to keywords
|
||||||
|
elif self.content_type != 'product_desc' and self.source_type == 'products':
|
||||||
|
self.source_type = 'keywords'
|
||||||
|
|
||||||
|
@api.onchange('template_id')
|
||||||
|
def _onchange_template_id(self):
|
||||||
|
"""Apply template settings to batch."""
|
||||||
|
if self.template_id:
|
||||||
|
self.tone = self.template_id.default_tone
|
||||||
|
self.target_word_count = self.template_id.default_word_count
|
||||||
|
self.include_images = self.template_id.include_images
|
||||||
|
self.image_count = self.template_id.default_image_count
|
||||||
|
self.image_style = self.template_id.default_image_style
|
||||||
|
|
||||||
|
def action_prepare_items(self):
|
||||||
|
"""Parse source and ADD items to batch (preserves existing items)."""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
# Collect existing items to avoid duplicates
|
||||||
|
existing_keys = set()
|
||||||
|
for item in self.item_ids:
|
||||||
|
if item.product_id:
|
||||||
|
existing_keys.add(f"product_{item.product_id.id}")
|
||||||
|
elif item.keywords:
|
||||||
|
existing_keys.add(f"keywords_{item.keywords.lower().strip()}")
|
||||||
|
|
||||||
|
items_data = []
|
||||||
|
|
||||||
|
if self.source_type == 'products':
|
||||||
|
if not self.product_ids:
|
||||||
|
raise UserError(_("Please select at least one product."))
|
||||||
|
for product in self.product_ids:
|
||||||
|
key = f"product_{product.id}"
|
||||||
|
if key not in existing_keys:
|
||||||
|
items_data.append({
|
||||||
|
'batch_id': self.id,
|
||||||
|
'name': product.name,
|
||||||
|
'product_id': product.id,
|
||||||
|
'keywords': product.name,
|
||||||
|
'topic': self._prepare_product_brief(product),
|
||||||
|
})
|
||||||
|
# Clear product selection after adding
|
||||||
|
self.product_ids = [(5, 0, 0)]
|
||||||
|
|
||||||
|
elif self.source_type == 'csv':
|
||||||
|
if not self.csv_file:
|
||||||
|
raise UserError(_("Please upload a CSV file."))
|
||||||
|
csv_items = self._parse_csv()
|
||||||
|
for item in csv_items:
|
||||||
|
key = f"keywords_{item.get('keywords', '').lower().strip()}"
|
||||||
|
if key not in existing_keys:
|
||||||
|
item['batch_id'] = self.id
|
||||||
|
items_data.append(item)
|
||||||
|
# Clear CSV after adding
|
||||||
|
self.csv_file = False
|
||||||
|
self.csv_filename = False
|
||||||
|
|
||||||
|
elif self.source_type == 'keywords':
|
||||||
|
if not self.keywords_list:
|
||||||
|
raise UserError(_("Please enter keywords or topics."))
|
||||||
|
for line in self.keywords_list.strip().split('\n'):
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
key = f"keywords_{line.lower()}"
|
||||||
|
if key not in existing_keys:
|
||||||
|
items_data.append({
|
||||||
|
'batch_id': self.id,
|
||||||
|
'name': line[:100],
|
||||||
|
'keywords': line,
|
||||||
|
})
|
||||||
|
# Clear keywords list after adding
|
||||||
|
self.keywords_list = False
|
||||||
|
|
||||||
|
if not items_data:
|
||||||
|
raise UserError(_("No new items to add. Items may already exist in the batch."))
|
||||||
|
|
||||||
|
# Create new batch items
|
||||||
|
self.env['otk.seo.content.batch.item'].create(items_data)
|
||||||
|
|
||||||
|
def action_clear_items(self):
|
||||||
|
"""Remove all items from the batch."""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if self.state not in ('draft',):
|
||||||
|
raise UserError(_("Can only clear items when batch is in Draft state."))
|
||||||
|
|
||||||
|
self.item_ids.unlink()
|
||||||
|
|
||||||
|
def _prepare_product_brief(self, product):
|
||||||
|
"""Prepare product information for AI."""
|
||||||
|
brief = f"Product: {product.name}\n"
|
||||||
|
if product.description:
|
||||||
|
brief += f"Description: {product.description}\n"
|
||||||
|
if product.list_price:
|
||||||
|
brief += f"Price: {product.list_price}\n"
|
||||||
|
if product.categ_id:
|
||||||
|
brief += f"Category: {product.categ_id.complete_name}\n"
|
||||||
|
return brief
|
||||||
|
|
||||||
|
def _parse_csv(self):
|
||||||
|
"""Parse CSV file and return items data (without batch_id)."""
|
||||||
|
items_data = []
|
||||||
|
try:
|
||||||
|
csv_data = base64.b64decode(self.csv_file).decode('utf-8')
|
||||||
|
reader = csv.DictReader(io.StringIO(csv_data))
|
||||||
|
|
||||||
|
for row in reader:
|
||||||
|
# Expected columns: keywords, topic (optional), name (optional)
|
||||||
|
keywords = row.get('keywords', row.get('keyword', ''))
|
||||||
|
topic = row.get('topic', row.get('brief', ''))
|
||||||
|
name = row.get('name', keywords[:100] if keywords else 'Untitled')
|
||||||
|
|
||||||
|
if keywords or topic:
|
||||||
|
items_data.append({
|
||||||
|
'name': name,
|
||||||
|
'keywords': keywords,
|
||||||
|
'topic': topic,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
raise UserError(_("Error parsing CSV file: %s") % str(e))
|
||||||
|
|
||||||
|
if not items_data:
|
||||||
|
raise UserError(_("No valid items found in CSV. Expected columns: keywords, topic (optional)"))
|
||||||
|
|
||||||
|
return items_data
|
||||||
|
|
||||||
|
def action_start_batch(self):
|
||||||
|
"""Queue the batch for async processing via cron."""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if not self.item_ids:
|
||||||
|
self.action_prepare_items()
|
||||||
|
|
||||||
|
if not self.item_ids:
|
||||||
|
raise UserError(_("No items to process."))
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'state': 'queued',
|
||||||
|
'started_at': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Mark all items as pending - they will be picked up by the cron job
|
||||||
|
self.item_ids.write({'state': 'pending'})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'display_notification',
|
||||||
|
'params': {
|
||||||
|
'title': _('Batch Queued'),
|
||||||
|
'message': _('Batch has been queued for processing. Items will be processed asynchronously.'),
|
||||||
|
'type': 'success',
|
||||||
|
'sticky': False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_pause(self):
|
||||||
|
"""Pause the batch processing."""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state in ('queued', 'processing'):
|
||||||
|
self.write({'state': 'paused'})
|
||||||
|
|
||||||
|
def action_resume(self):
|
||||||
|
"""Resume paused batch processing."""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state == 'paused':
|
||||||
|
self.write({'state': 'processing'})
|
||||||
|
|
||||||
|
def action_cancel(self):
|
||||||
|
"""Cancel the batch."""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state in ('queued', 'processing', 'paused'):
|
||||||
|
self.write({'state': 'cancelled'})
|
||||||
|
self.item_ids.filtered(lambda i: i.state == 'pending').write({'state': 'cancelled'})
|
||||||
|
return True
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def cron_process_batch_items(self):
|
||||||
|
"""Cron job to process batch items asynchronously.
|
||||||
|
|
||||||
|
Processes up to `concurrency` items per active batch per cron run.
|
||||||
|
Skips paused and cancelled batches.
|
||||||
|
"""
|
||||||
|
# Find all batches that are queued or processing (not paused)
|
||||||
|
active_batches = self.search([
|
||||||
|
('state', 'in', ['queued', 'processing']),
|
||||||
|
])
|
||||||
|
|
||||||
|
for batch in active_batches:
|
||||||
|
# Skip if cancelled or paused
|
||||||
|
if batch.state in ('cancelled', 'paused'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get pending items up to concurrency limit
|
||||||
|
items_limit = min(batch.concurrency or 5, 10)
|
||||||
|
pending_items = batch.item_ids.filtered(lambda i: i.state == 'pending')[:items_limit]
|
||||||
|
|
||||||
|
if not pending_items:
|
||||||
|
# All items processed, complete the batch
|
||||||
|
batch._complete_batch()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update batch state to processing if it was queued
|
||||||
|
if batch.state == 'queued':
|
||||||
|
batch.state = 'processing'
|
||||||
|
|
||||||
|
# Process items (with commit after each to preserve state)
|
||||||
|
for item in pending_items:
|
||||||
|
try:
|
||||||
|
item._process_item()
|
||||||
|
self.env.cr.commit()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error("Error processing batch item %s: %s", item.id, e)
|
||||||
|
item.write({
|
||||||
|
'state': 'failed',
|
||||||
|
'error_message': str(e),
|
||||||
|
})
|
||||||
|
self.env.cr.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _complete_batch(self):
|
||||||
|
"""Mark batch as completed."""
|
||||||
|
self.ensure_one()
|
||||||
|
failed = self.item_ids.filtered(lambda i: i.state == 'failed')
|
||||||
|
total = len(self.item_ids)
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
state = 'done'
|
||||||
|
elif len(failed) == total:
|
||||||
|
state = 'failed'
|
||||||
|
else:
|
||||||
|
state = 'done'
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'state': state,
|
||||||
|
'completed_at': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Send bus notification to batch creator
|
||||||
|
if self.create_uid and self.create_uid.partner_id:
|
||||||
|
succeeded = len(self.item_ids) - len(failed)
|
||||||
|
self.env['bus.bus']._sendone(
|
||||||
|
self.create_uid.partner_id,
|
||||||
|
'simple_notification',
|
||||||
|
{
|
||||||
|
'title': _("Batch Complete!"),
|
||||||
|
'message': _("Batch '%s' complete: %d/%d items succeeded.") % (
|
||||||
|
self.name, succeeded, len(self.item_ids)),
|
||||||
|
'type': 'success' if not failed else 'warning',
|
||||||
|
'sticky': False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_view_content(self):
|
||||||
|
"""View generated content."""
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _('Generated Content'),
|
||||||
|
'res_model': 'otk.seo.content',
|
||||||
|
'view_mode': 'list,form',
|
||||||
|
'domain': [('batch_id', '=', self.id)],
|
||||||
|
'context': {'default_batch_id': self.id},
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_retry_failed(self):
|
||||||
|
"""Retry failed items - re-queue them for async processing."""
|
||||||
|
self.ensure_one()
|
||||||
|
failed_items = self.item_ids.filtered(lambda i: i.state == 'failed')
|
||||||
|
|
||||||
|
if not failed_items:
|
||||||
|
raise UserError(_("No failed items to retry."))
|
||||||
|
|
||||||
|
failed_items.write({'state': 'pending', 'error_message': False})
|
||||||
|
self.write({'state': 'queued'})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'display_notification',
|
||||||
|
'params': {
|
||||||
|
'title': _('Items Re-queued'),
|
||||||
|
'message': _('%d failed items have been re-queued for processing.') % len(failed_items),
|
||||||
|
'type': 'success',
|
||||||
|
'sticky': False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SeoContentBatchItem(models.Model):
|
||||||
|
_name = 'otk.seo.content.batch.item'
|
||||||
|
_description = 'SEO Content Batch Item'
|
||||||
|
_order = 'sequence, id'
|
||||||
|
|
||||||
|
batch_id = fields.Many2one('otk.seo.content.batch', string='Batch',
|
||||||
|
required=True, ondelete='cascade')
|
||||||
|
sequence = fields.Integer('Sequence', default=10)
|
||||||
|
name = fields.Char('Name', required=True)
|
||||||
|
|
||||||
|
state = fields.Selection([
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('processing', 'Processing'),
|
||||||
|
('done', 'Completed'),
|
||||||
|
('failed', 'Failed'),
|
||||||
|
('cancelled', 'Cancelled'),
|
||||||
|
], string='Status', default='draft')
|
||||||
|
|
||||||
|
# === Source Data ===
|
||||||
|
product_id = fields.Many2one('product.template', 'Product')
|
||||||
|
keywords = fields.Char('Keywords')
|
||||||
|
topic = fields.Text('Topic/Brief')
|
||||||
|
|
||||||
|
# === Result ===
|
||||||
|
content_id = fields.Many2one('otk.seo.content', 'Generated Content', ondelete='set null')
|
||||||
|
error_message = fields.Text('Error Message')
|
||||||
|
|
||||||
|
def _process_item(self):
|
||||||
|
"""Process this batch item - generate content."""
|
||||||
|
self.ensure_one()
|
||||||
|
self.state = 'processing'
|
||||||
|
|
||||||
|
try:
|
||||||
|
batch = self.batch_id
|
||||||
|
|
||||||
|
# Create content record
|
||||||
|
content_vals = {
|
||||||
|
'name': self.name,
|
||||||
|
'content_type': batch.content_type,
|
||||||
|
'source_keywords': self.keywords,
|
||||||
|
'source_topic': self.topic,
|
||||||
|
'source_product_id': self.product_id.id if self.product_id else False,
|
||||||
|
'template_id': batch.template_id.id if batch.template_id else False,
|
||||||
|
'brand_voice_id': batch.brand_voice_id.id if batch.brand_voice_id else False,
|
||||||
|
'tone': batch.tone,
|
||||||
|
'target_word_count': batch.target_word_count,
|
||||||
|
'language_id': batch.language_id.id if batch.language_id else False,
|
||||||
|
'requested_image_count': batch.image_count if batch.include_images else 0,
|
||||||
|
'image_style': batch.image_style if batch.include_images else False,
|
||||||
|
'blog_id': batch.blog_id.id if batch.blog_id else False,
|
||||||
|
'batch_id': batch.id,
|
||||||
|
'state': 'draft',
|
||||||
|
}
|
||||||
|
|
||||||
|
content = self.env['otk.seo.content'].create(content_vals)
|
||||||
|
self.content_id = content.id
|
||||||
|
|
||||||
|
# Generate content
|
||||||
|
content.action_generate()
|
||||||
|
|
||||||
|
self.state = 'done'
|
||||||
|
_logger.info(f"Batch item {self.id} processed successfully: content {content.id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.state = 'failed'
|
||||||
|
self.error_message = str(e)
|
||||||
|
_logger.error(f"Batch item {self.id} failed: {e}")
|
||||||
|
|
||||||
|
return True
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OtkSeoContentIdea(models.Model):
|
||||||
|
_name = 'otk.seo.content.idea'
|
||||||
|
_description = 'SEO Content Idea Queue'
|
||||||
|
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||||
|
_order = 'scheduled_date, priority desc, create_date'
|
||||||
|
|
||||||
|
name = fields.Char('Title/Topic', required=True, tracking=True,
|
||||||
|
help='Working title or topic for this content idea.')
|
||||||
|
|
||||||
|
# === Source Information ===
|
||||||
|
keywords = fields.Char('Target Keywords',
|
||||||
|
help='Primary keywords for this content.')
|
||||||
|
topic_brief = fields.Text('Topic Brief',
|
||||||
|
help='Description of what the content should cover.')
|
||||||
|
product_id = fields.Many2one('product.template', 'Related Product',
|
||||||
|
help='Product to generate content about.')
|
||||||
|
reference_ids = fields.Many2many('otk.seo.reference',
|
||||||
|
'otk_seo_idea_reference_rel', 'idea_id', 'reference_id',
|
||||||
|
string='References',
|
||||||
|
help='Reference materials to provide context for content generation.')
|
||||||
|
|
||||||
|
# === Priority & Scheduling ===
|
||||||
|
priority = fields.Selection([
|
||||||
|
('0', 'Low'),
|
||||||
|
('1', 'Normal'),
|
||||||
|
('2', 'High'),
|
||||||
|
('3', 'Urgent'),
|
||||||
|
], string='Priority', default='1', index=True, tracking=True,
|
||||||
|
help='Priority level for processing. Higher priority ideas are processed first.')
|
||||||
|
|
||||||
|
scheduled_date = fields.Datetime('Scheduled For', tracking=True,
|
||||||
|
help='When this content should be generated. Leave empty for manual processing only.')
|
||||||
|
|
||||||
|
# === Generation Settings ===
|
||||||
|
content_type = fields.Selection([
|
||||||
|
('blog_post', 'Blog Post'),
|
||||||
|
('product_desc', 'Product Description'),
|
||||||
|
('category_desc', 'Category Description'),
|
||||||
|
('landing_page', 'Landing Page Content'),
|
||||||
|
('social_post', 'Social Media Post'),
|
||||||
|
], string='Content Type', default='blog_post', required=True,
|
||||||
|
help='The type of content to generate.')
|
||||||
|
|
||||||
|
template_id = fields.Many2one('otk.seo.template', 'Content Template',
|
||||||
|
domain="[('content_type', '=', content_type)]",
|
||||||
|
help='Template to use for content generation.')
|
||||||
|
brand_voice_id = fields.Many2one('otk.seo.brand.voice', 'Brand Voice',
|
||||||
|
default=lambda self: self.env['otk.seo.brand.voice'].get_default_voice(),
|
||||||
|
help='Brand voice guidelines to apply.')
|
||||||
|
tone = fields.Selection([
|
||||||
|
('professional', 'Professional'),
|
||||||
|
('casual', 'Casual'),
|
||||||
|
('technical', 'Technical'),
|
||||||
|
('persuasive', 'Persuasive'),
|
||||||
|
('conversational', 'Conversational'),
|
||||||
|
], string='Tone', default='professional',
|
||||||
|
help='The writing style for generated content.')
|
||||||
|
target_word_count = fields.Selection([
|
||||||
|
('short', 'Short (300-500 words)'),
|
||||||
|
('medium', 'Medium (500-1000 words)'),
|
||||||
|
('long', 'Long (1000-2000 words)'),
|
||||||
|
], string='Target Length', default='medium',
|
||||||
|
help='Approximate length of generated content.')
|
||||||
|
language_id = fields.Many2one('res.lang', 'Language',
|
||||||
|
default=lambda self: self._default_language(),
|
||||||
|
help='Language for content generation.')
|
||||||
|
|
||||||
|
# === Image Settings ===
|
||||||
|
include_images = fields.Boolean('Generate Images', default=True,
|
||||||
|
help='Generate images along with the content.')
|
||||||
|
image_count = fields.Integer('Image Count', default=1,
|
||||||
|
help='Number of images to generate.')
|
||||||
|
image_style = fields.Selection([
|
||||||
|
('photorealistic', 'Photorealistic'),
|
||||||
|
('illustration', 'Illustration'),
|
||||||
|
('digital_art', 'Digital Art'),
|
||||||
|
('watercolor', 'Watercolor'),
|
||||||
|
('sketch', 'Sketch'),
|
||||||
|
('3d_render', '3D Render'),
|
||||||
|
], string='Image Style', default='photorealistic',
|
||||||
|
help='Visual style for generated images.')
|
||||||
|
|
||||||
|
# === Auto-Publish Settings ===
|
||||||
|
auto_create_blog_post = fields.Boolean('Auto-Create Blog Post',
|
||||||
|
help='Automatically create a blog post when content is generated and approved.')
|
||||||
|
blog_id = fields.Many2one('blog.blog', 'Target Blog',
|
||||||
|
help='Blog where the post will be created.')
|
||||||
|
auto_publish = fields.Boolean('Auto-Publish Post',
|
||||||
|
help='Automatically publish the blog post (make it visible on the website).')
|
||||||
|
|
||||||
|
# === State ===
|
||||||
|
state = fields.Selection([
|
||||||
|
('idea', 'Idea'),
|
||||||
|
('scheduled', 'Scheduled'),
|
||||||
|
('generating', 'Generating'),
|
||||||
|
('done', 'Done'),
|
||||||
|
('failed', 'Failed'),
|
||||||
|
('cancelled', 'Cancelled'),
|
||||||
|
], string='Status', default='idea', tracking=True, index=True, copy=False,
|
||||||
|
help='Current status of the idea.')
|
||||||
|
|
||||||
|
# === Result ===
|
||||||
|
content_id = fields.Many2one('otk.seo.content', 'Generated Content',
|
||||||
|
readonly=True, ondelete='set null', copy=False,
|
||||||
|
help='The content record created from this idea.')
|
||||||
|
blog_post_id = fields.Many2one('blog.post', 'Blog Post',
|
||||||
|
related='content_id.blog_post_id', readonly=True,
|
||||||
|
help='The blog post created from the generated content.')
|
||||||
|
error_message = fields.Text('Error Message', copy=False,
|
||||||
|
help='Error details if generation failed.')
|
||||||
|
|
||||||
|
# === Recurring ===
|
||||||
|
is_recurring = fields.Boolean('Recurring',
|
||||||
|
help='If enabled, a new idea will be automatically created after this one is processed.')
|
||||||
|
recurrence_type = fields.Selection([
|
||||||
|
('daily', 'Daily'),
|
||||||
|
('weekly', 'Weekly'),
|
||||||
|
('biweekly', 'Every 2 Weeks'),
|
||||||
|
('monthly', 'Monthly'),
|
||||||
|
], string='Recurrence', default='weekly',
|
||||||
|
help='How often a new idea should be created.')
|
||||||
|
recurrence_end_date = fields.Date('Recurrence End Date',
|
||||||
|
help='Stop creating new occurrences after this date. Leave empty for no end date.')
|
||||||
|
parent_idea_id = fields.Many2one('otk.seo.content.idea', 'Parent Idea',
|
||||||
|
readonly=True, ondelete='set null', copy=False,
|
||||||
|
help='The recurring idea that spawned this occurrence.')
|
||||||
|
|
||||||
|
# === Tracking ===
|
||||||
|
generated_at = fields.Datetime('Generated At', readonly=True, copy=False,
|
||||||
|
help='When the content was successfully generated.')
|
||||||
|
|
||||||
|
def _default_language(self):
|
||||||
|
"""Get default language."""
|
||||||
|
lang_code = self.env.lang or 'en_US'
|
||||||
|
lang = self.env['res.lang']._lang_get(lang_code)
|
||||||
|
return lang.id if lang else False
|
||||||
|
|
||||||
|
@api.onchange('scheduled_date')
|
||||||
|
def _onchange_scheduled_date(self):
|
||||||
|
"""Update state based on scheduled date."""
|
||||||
|
for record in self:
|
||||||
|
if record.scheduled_date and record.state == 'idea':
|
||||||
|
record.state = 'scheduled'
|
||||||
|
elif not record.scheduled_date and record.state == 'scheduled':
|
||||||
|
record.state = 'idea'
|
||||||
|
|
||||||
|
def action_schedule_now(self):
|
||||||
|
"""Schedule this idea for immediate generation by cron."""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state not in ('idea', 'scheduled'):
|
||||||
|
raise UserError(_("Can only schedule ideas that are in 'Idea' or 'Scheduled' state."))
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'scheduled_date': fields.Datetime.now(),
|
||||||
|
'state': 'scheduled',
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'display_notification',
|
||||||
|
'params': {
|
||||||
|
'title': _('Scheduled'),
|
||||||
|
'message': _('Idea scheduled for immediate processing.'),
|
||||||
|
'type': 'success',
|
||||||
|
'sticky': False,
|
||||||
|
'next': {'type': 'ir.actions.client', 'tag': 'soft_reload'},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_generate_now(self):
|
||||||
|
"""Generate content immediately (bypass cron schedule)."""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state not in ('idea', 'scheduled'):
|
||||||
|
raise UserError(_("Can only generate from 'Idea' or 'Scheduled' state."))
|
||||||
|
|
||||||
|
self._process_idea()
|
||||||
|
|
||||||
|
if self.state == 'done' and self.content_id:
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'otk.seo.content',
|
||||||
|
'res_id': self.content_id.id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'current',
|
||||||
|
}
|
||||||
|
return True
|
||||||
|
|
||||||
|
def action_cancel(self):
|
||||||
|
"""Cancel this idea."""
|
||||||
|
for record in self:
|
||||||
|
if record.state in ('idea', 'scheduled'):
|
||||||
|
record.write({'state': 'cancelled'})
|
||||||
|
|
||||||
|
def action_reset_to_idea(self):
|
||||||
|
"""Reset failed/cancelled idea back to idea state."""
|
||||||
|
for record in self:
|
||||||
|
if record.state in ('failed', 'cancelled'):
|
||||||
|
record.write({
|
||||||
|
'state': 'idea',
|
||||||
|
'scheduled_date': False,
|
||||||
|
'error_message': False,
|
||||||
|
})
|
||||||
|
|
||||||
|
def action_view_content(self):
|
||||||
|
"""Open the generated content."""
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.content_id:
|
||||||
|
raise UserError(_("No content has been generated yet."))
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'otk.seo.content',
|
||||||
|
'res_id': self.content_id.id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'current',
|
||||||
|
}
|
||||||
|
|
||||||
|
def _process_idea(self):
|
||||||
|
"""Submit content generation for this idea (async).
|
||||||
|
|
||||||
|
Creates content record and submits for generation. The content
|
||||||
|
will be generated asynchronously by the API. A separate cron job
|
||||||
|
monitors idea completion.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'state': 'generating',
|
||||||
|
'error_message': False,
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build content values
|
||||||
|
content_vals = {
|
||||||
|
'name': self.name,
|
||||||
|
'content_type': self.content_type,
|
||||||
|
'source_keywords': self.keywords,
|
||||||
|
'source_topic': self.topic_brief,
|
||||||
|
'source_product_id': self.product_id.id if self.product_id else False,
|
||||||
|
'template_id': self.template_id.id if self.template_id else False,
|
||||||
|
'brand_voice_id': self.brand_voice_id.id if self.brand_voice_id else False,
|
||||||
|
'tone': self.tone,
|
||||||
|
'target_word_count': self.target_word_count,
|
||||||
|
'language_id': self.language_id.id if self.language_id else False,
|
||||||
|
'requested_image_count': self.image_count if self.include_images else 0,
|
||||||
|
'image_style': self.image_style if self.include_images else False,
|
||||||
|
'blog_id': self.blog_id.id if self.blog_id else False,
|
||||||
|
'reference_ids': [(6, 0, self.reference_ids.ids)] if self.reference_ids else False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create content record
|
||||||
|
content = self.env['otk.seo.content'].create(content_vals)
|
||||||
|
self.content_id = content.id
|
||||||
|
|
||||||
|
# Submit content for async generation
|
||||||
|
content.action_generate()
|
||||||
|
|
||||||
|
# Create next occurrence for recurring ideas
|
||||||
|
self._create_next_occurrence()
|
||||||
|
|
||||||
|
# Content is now in 'generating' state - cron will check completion
|
||||||
|
_logger.info(f"Idea {self.id} '{self.name}' submitted for content generation: content {content.id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception(f"Idea {self.id} failed: {e}")
|
||||||
|
self.write({
|
||||||
|
'state': 'failed',
|
||||||
|
'error_message': str(e),
|
||||||
|
})
|
||||||
|
|
||||||
|
def _create_next_occurrence(self):
|
||||||
|
"""Create the next occurrence if this is a recurring idea."""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if not self.is_recurring:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if recurrence has ended
|
||||||
|
if self.recurrence_end_date and fields.Date.today() >= self.recurrence_end_date:
|
||||||
|
_logger.info("Recurring idea %s reached end date %s, no more occurrences",
|
||||||
|
self.id, self.recurrence_end_date)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Compute next scheduled date
|
||||||
|
base_date = self.scheduled_date or fields.Datetime.now()
|
||||||
|
interval_map = {
|
||||||
|
'daily': timedelta(days=1),
|
||||||
|
'weekly': timedelta(weeks=1),
|
||||||
|
'biweekly': timedelta(weeks=2),
|
||||||
|
'monthly': timedelta(days=30),
|
||||||
|
}
|
||||||
|
delta = interval_map.get(self.recurrence_type, timedelta(weeks=1))
|
||||||
|
next_date = base_date + delta
|
||||||
|
|
||||||
|
# Check if next date exceeds end date
|
||||||
|
if self.recurrence_end_date and next_date.date() > self.recurrence_end_date:
|
||||||
|
_logger.info("Next occurrence for idea %s would exceed end date, skipping", self.id)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create next occurrence
|
||||||
|
next_idea = self.copy({
|
||||||
|
'scheduled_date': next_date,
|
||||||
|
'state': 'scheduled',
|
||||||
|
'parent_idea_id': self.id,
|
||||||
|
'is_recurring': True,
|
||||||
|
'recurrence_type': self.recurrence_type,
|
||||||
|
'recurrence_end_date': self.recurrence_end_date,
|
||||||
|
})
|
||||||
|
|
||||||
|
_logger.info("Created recurring occurrence %s for idea %s, scheduled at %s",
|
||||||
|
next_idea.id, self.id, next_date)
|
||||||
|
|
||||||
|
def _check_content_completion(self):
|
||||||
|
"""Check if linked content has completed and finalize idea if so."""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if not self.content_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
content = self.content_id
|
||||||
|
|
||||||
|
# Check if content has finished generating
|
||||||
|
if content.state in ('review', 'images_pending', 'approved', 'published'):
|
||||||
|
# Auto-approve and create blog post if configured
|
||||||
|
if self.auto_create_blog_post and content.state == 'review':
|
||||||
|
content.action_approve()
|
||||||
|
content.action_create_blog_post()
|
||||||
|
|
||||||
|
# Auto-publish if enabled
|
||||||
|
if self.auto_publish and content.blog_post_id:
|
||||||
|
content.blog_post_id.is_published = True
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'state': 'done',
|
||||||
|
'generated_at': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
_logger.info(f"Idea {self.id} '{self.name}' completed: content {content.id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif content.state == 'draft' and content.error_message:
|
||||||
|
# Content generation failed
|
||||||
|
self.write({
|
||||||
|
'state': 'failed',
|
||||||
|
'error_message': content.error_message,
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Still generating
|
||||||
|
return False
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def cron_process_scheduled_ideas(self):
|
||||||
|
"""Cron job to process scheduled ideas.
|
||||||
|
|
||||||
|
Runs periodically. Processes ideas whose scheduled_date has passed.
|
||||||
|
"""
|
||||||
|
now = fields.Datetime.now()
|
||||||
|
|
||||||
|
scheduled_ideas = self.search([
|
||||||
|
('state', '=', 'scheduled'),
|
||||||
|
('scheduled_date', '<=', now),
|
||||||
|
], order='priority desc, scheduled_date', limit=5) # Process up to 5 per run
|
||||||
|
|
||||||
|
_logger.info(f"Processing {len(scheduled_ideas)} scheduled ideas")
|
||||||
|
|
||||||
|
for idea in scheduled_ideas:
|
||||||
|
try:
|
||||||
|
idea._process_idea()
|
||||||
|
# Commit after each to prevent full rollback on error
|
||||||
|
self.env.cr.commit()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"Error processing scheduled idea {idea.id}: {e}")
|
||||||
|
idea.write({
|
||||||
|
'state': 'failed',
|
||||||
|
'error_message': str(e),
|
||||||
|
})
|
||||||
|
self.env.cr.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def cron_check_generating_ideas(self):
|
||||||
|
"""Cron job to check if generating ideas have completed.
|
||||||
|
|
||||||
|
Runs periodically. Checks ideas in 'generating' state and
|
||||||
|
marks them done/failed based on linked content status.
|
||||||
|
"""
|
||||||
|
generating_ideas = self.search([
|
||||||
|
('state', '=', 'generating'),
|
||||||
|
('content_id', '!=', False),
|
||||||
|
], limit=20)
|
||||||
|
|
||||||
|
_logger.info(f"Checking {len(generating_ideas)} generating ideas")
|
||||||
|
|
||||||
|
for idea in generating_ideas:
|
||||||
|
try:
|
||||||
|
idea._check_content_completion()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"Error checking idea {idea.id} completion: {e}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def copy(self, default=None):
|
||||||
|
"""Reset state when duplicating."""
|
||||||
|
default = dict(default or {})
|
||||||
|
default.setdefault('state', 'idea')
|
||||||
|
default.setdefault('scheduled_date', False)
|
||||||
|
default.setdefault('content_id', False)
|
||||||
|
default.setdefault('error_message', False)
|
||||||
|
default.setdefault('generated_at', False)
|
||||||
|
return super().copy(default)
|
||||||
@@ -0,0 +1,728 @@
|
|||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
HAS_PILLOW = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_PILLOW = False
|
||||||
|
_logger.warning("Pillow not installed. Image optimization features will be disabled.")
|
||||||
|
|
||||||
|
|
||||||
|
class OtkSeoContentImage(models.Model):
|
||||||
|
_name = 'otk.seo.content.image'
|
||||||
|
_description = 'SEO Content Generated Image'
|
||||||
|
_order = 'sequence, id'
|
||||||
|
|
||||||
|
content_id = fields.Many2one('otk.seo.content', 'Content',
|
||||||
|
required=True, ondelete='cascade', index=True,
|
||||||
|
help='The SEO content record this image belongs to.')
|
||||||
|
|
||||||
|
sequence = fields.Integer('Sequence', default=10,
|
||||||
|
help='Order in which images appear. Lower numbers appear first.')
|
||||||
|
name = fields.Char('Image Name',
|
||||||
|
help='A descriptive name for this image. Auto-generated from the prompt if not provided.')
|
||||||
|
|
||||||
|
# === Generation Settings ===
|
||||||
|
prompt = fields.Text('Generation Prompt', required=True,
|
||||||
|
help='The text description sent to the AI to generate this image. Be specific about style, subject, composition, and mood.')
|
||||||
|
negative_prompt = fields.Text('Negative Prompt',
|
||||||
|
default='text, watermark, low quality, blurry, distorted',
|
||||||
|
help='Elements to avoid in the generated image. The AI will try not to include these aspects.')
|
||||||
|
|
||||||
|
style = fields.Selection([
|
||||||
|
('photorealistic', 'Photorealistic'),
|
||||||
|
('illustration', 'Illustration'),
|
||||||
|
('digital_art', 'Digital Art'),
|
||||||
|
('watercolor', 'Watercolor'),
|
||||||
|
('sketch', 'Sketch'),
|
||||||
|
('3d_render', '3D Render'),
|
||||||
|
], string='Style', default='photorealistic',
|
||||||
|
help='Visual style for the generated image. Photorealistic creates photo-like images; other styles create artistic interpretations.')
|
||||||
|
|
||||||
|
aspect_ratio = fields.Selection([
|
||||||
|
('1024x1024', 'Square (1:1)'),
|
||||||
|
('1792x1024', 'Landscape (16:9)'),
|
||||||
|
('1024x1792', 'Portrait (9:16)'),
|
||||||
|
], string='Size', default='1792x1024',
|
||||||
|
help='Image dimensions. Landscape (16:9) is ideal for blog headers; Square (1:1) works well for social media; Portrait (9:16) for mobile-first content.')
|
||||||
|
|
||||||
|
quality = fields.Selection([
|
||||||
|
('standard', 'Standard'),
|
||||||
|
('hd', 'HD'),
|
||||||
|
], string='Quality', default='standard',
|
||||||
|
help='Image quality level. HD produces sharper images with more detail but uses more tokens.')
|
||||||
|
|
||||||
|
# === Task Tracking ===
|
||||||
|
task_id = fields.Char('API Task ID', index=True,
|
||||||
|
help='Internal identifier for tracking this image generation task in the O\'Toolkit API.')
|
||||||
|
state = fields.Selection([
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('processing', 'Processing'),
|
||||||
|
('done', 'Done'),
|
||||||
|
('error', 'Error'),
|
||||||
|
], string='Status', default='draft', index=True,
|
||||||
|
help='Current status of image generation. Draft: not submitted; Pending: waiting to be sent; Processing: AI is generating; Done: image ready; Error: generation failed.')
|
||||||
|
|
||||||
|
# === Result ===
|
||||||
|
image = fields.Binary('Image', attachment=True,
|
||||||
|
help='The generated image file. Stored as an Odoo attachment.')
|
||||||
|
image_filename = fields.Char('Filename',
|
||||||
|
help='The filename for the image when downloaded.')
|
||||||
|
revised_prompt = fields.Text('Revised Prompt',
|
||||||
|
help='The prompt as revised by the AI for better generation. The AI may modify your prompt to produce better results.')
|
||||||
|
|
||||||
|
# === Optimization ===
|
||||||
|
image_original = fields.Binary('Original Image', attachment=True,
|
||||||
|
help='The original unoptimized image. Kept as backup before optimization.')
|
||||||
|
image_webp = fields.Binary('WebP Image', attachment=True,
|
||||||
|
help='WebP version of the image for modern browsers. Smaller file size with same quality.')
|
||||||
|
|
||||||
|
original_size = fields.Integer('Original Size (bytes)',
|
||||||
|
help='File size of the original generated image before any optimization.')
|
||||||
|
optimized_size = fields.Integer('Optimized Size (bytes)',
|
||||||
|
help='File size after optimization. Lower is better for web performance.')
|
||||||
|
webp_size = fields.Integer('WebP Size (bytes)',
|
||||||
|
help='File size of the WebP version.')
|
||||||
|
size_reduction = fields.Float('Size Reduction %', compute='_compute_size_reduction',
|
||||||
|
help='Percentage reduction in file size from optimization.')
|
||||||
|
|
||||||
|
width = fields.Integer('Width (px)',
|
||||||
|
help='Image width in pixels.')
|
||||||
|
height = fields.Integer('Height (px)',
|
||||||
|
help='Image height in pixels.')
|
||||||
|
|
||||||
|
is_optimized = fields.Boolean('Optimized', default=False,
|
||||||
|
help='Indicates if this image has been optimized for web.')
|
||||||
|
optimization_quality = fields.Integer('Optimization Quality', default=85,
|
||||||
|
help='JPEG/WebP quality level (1-100). Lower means smaller files but less quality. 85 is a good balance.')
|
||||||
|
|
||||||
|
# === Usage ===
|
||||||
|
alt_text = fields.Char('Alt Text',
|
||||||
|
help='SEO-friendly alt text describing the image. Important for accessibility and search engine optimization.')
|
||||||
|
is_cover = fields.Boolean('Use as Cover Image',
|
||||||
|
help='If enabled, this image will be used as the cover/header image for the blog post.')
|
||||||
|
is_og_image = fields.Boolean('Use as OpenGraph Image',
|
||||||
|
help='If enabled, this image will be used for social media sharing previews (Facebook, Twitter, LinkedIn).')
|
||||||
|
inserted_in_content = fields.Boolean('Inserted in Content',
|
||||||
|
help='Indicates whether this image has been embedded within the content body.')
|
||||||
|
|
||||||
|
# === Tracking ===
|
||||||
|
token_cost = fields.Integer('Tokens Used',
|
||||||
|
help='Number of API tokens consumed to generate this image. HD quality images cost more tokens.')
|
||||||
|
error_message = fields.Text('Error Message',
|
||||||
|
help='If generation failed, this contains the error details. Check this to troubleshoot issues.')
|
||||||
|
|
||||||
|
# === Retry Logic ===
|
||||||
|
retry_count = fields.Integer('Retry Count', default=0,
|
||||||
|
help='Number of retry attempts made for this image generation.')
|
||||||
|
max_retries = fields.Integer('Max Retries', default=3,
|
||||||
|
help='Maximum number of retry attempts before permanently failing.')
|
||||||
|
next_retry_at = fields.Datetime('Next Retry At',
|
||||||
|
help='Scheduled time for next retry attempt.')
|
||||||
|
processing_since = fields.Datetime('Processing Since',
|
||||||
|
help='Timestamp of when processing started, for stuck task detection.')
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
"""Auto-generate name if not provided."""
|
||||||
|
for vals in vals_list:
|
||||||
|
if not vals.get('name') and vals.get('prompt'):
|
||||||
|
vals['name'] = vals['prompt'][:50] + '...' if len(vals['prompt']) > 50 else vals['prompt']
|
||||||
|
return super().create(vals_list)
|
||||||
|
|
||||||
|
def _get_api_config(self):
|
||||||
|
"""Get API configuration."""
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
base_url = ICP.get_param('otoolkit.api.endpoint', '')
|
||||||
|
api_key = ICP.get_param('otoolkit_api_key', '')
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
raise UserError(_("Please configure your O'Toolkit API key in Settings."))
|
||||||
|
if not base_url:
|
||||||
|
raise UserError(_("O'Toolkit API endpoint is not configured."))
|
||||||
|
|
||||||
|
return base_url, api_key
|
||||||
|
|
||||||
|
def action_generate(self):
|
||||||
|
"""Submit image for generation."""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if not self.prompt:
|
||||||
|
raise UserError(_("Please provide a generation prompt."))
|
||||||
|
|
||||||
|
base_url, api_key = self._get_api_config()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Odoo-Api-Key': api_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'prompt': self.prompt,
|
||||||
|
'style': self.style,
|
||||||
|
'size': self.aspect_ratio,
|
||||||
|
'quality': self.quality,
|
||||||
|
'odoo_user_id': self.env.uid,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.negative_prompt:
|
||||||
|
payload['negative_prompt'] = self.negative_prompt
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{base_url.rstrip('/')}/api/seo-content/image/",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 400:
|
||||||
|
try:
|
||||||
|
error_data = response.json()
|
||||||
|
error_msg = error_data.get('error') or error_data.get('detail') or 'Unknown error'
|
||||||
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
error_msg = response.text[:500] if response.text else 'Unknown error'
|
||||||
|
raise UserError(_("API Error: %s") % error_msg)
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise UserError(_("API Error (%s): %s") % (response.status_code, response.text[:500]))
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = response.json()
|
||||||
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
raise UserError(_("Invalid response from API: %s") % response.text[:200])
|
||||||
|
|
||||||
|
if result.get('success') and result.get('task_id'):
|
||||||
|
self.write({
|
||||||
|
'task_id': result['task_id'],
|
||||||
|
'state': 'processing',
|
||||||
|
'error_message': False,
|
||||||
|
'processing_since': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
raise UserError(_("Failed to submit image generation: %s") % result.get('error', 'Unknown error'))
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
_logger.error(f"Image generation request error: {e}")
|
||||||
|
self.write({
|
||||||
|
'state': 'error',
|
||||||
|
'error_message': str(e),
|
||||||
|
})
|
||||||
|
raise UserError(_("Failed to submit image generation: %s") % str(e))
|
||||||
|
|
||||||
|
def action_retry(self):
|
||||||
|
"""Retry failed image generation."""
|
||||||
|
self.ensure_one()
|
||||||
|
self.write({
|
||||||
|
'state': 'pending',
|
||||||
|
'error_message': False,
|
||||||
|
'task_id': False,
|
||||||
|
})
|
||||||
|
return self.action_generate()
|
||||||
|
|
||||||
|
def action_regenerate(self):
|
||||||
|
"""Regenerate image with current settings (allows modifying prompt/style first)."""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if self.state not in ('done', 'error'):
|
||||||
|
raise UserError(_("Can only regenerate completed or failed images."))
|
||||||
|
|
||||||
|
# Store current image as backup (original) if we have an image and no backup yet
|
||||||
|
if self.image and not self.image_original:
|
||||||
|
self.image_original = self.image
|
||||||
|
|
||||||
|
# Reset state and clear generation results
|
||||||
|
self.write({
|
||||||
|
'state': 'pending',
|
||||||
|
'task_id': False,
|
||||||
|
'error_message': False,
|
||||||
|
'image': False,
|
||||||
|
'image_webp': False,
|
||||||
|
'revised_prompt': False,
|
||||||
|
'is_optimized': False,
|
||||||
|
'optimized_size': 0,
|
||||||
|
'webp_size': 0,
|
||||||
|
'token_cost': 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Submit for generation
|
||||||
|
return self.action_generate()
|
||||||
|
|
||||||
|
def action_poll_status(self):
|
||||||
|
"""Check the status of image generation."""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if not self.task_id:
|
||||||
|
raise UserError(_("No task ID found. Please generate the image first."))
|
||||||
|
|
||||||
|
base_url, api_key = self._get_api_config()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Odoo-Api-Key': api_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Safely convert task_id to int
|
||||||
|
try:
|
||||||
|
task_id_int = int(self.task_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise UserError(_("Invalid task ID format: %s") % self.task_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{base_url.rstrip('/')}/api/tasks/",
|
||||||
|
headers=headers,
|
||||||
|
json={'task_ids': [task_id_int]},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
_logger.error("Invalid JSON response from tasks API: %s", response.text[:200])
|
||||||
|
return
|
||||||
|
|
||||||
|
tasks = data.get('tasks', []) if isinstance(data, dict) else data
|
||||||
|
|
||||||
|
if tasks and len(tasks) > 0:
|
||||||
|
task = tasks[0]
|
||||||
|
status = task.get('status')
|
||||||
|
|
||||||
|
if status == 'completed':
|
||||||
|
result = task.get('result', {})
|
||||||
|
self._process_completed_image(result)
|
||||||
|
elif status == 'failed':
|
||||||
|
error_msg = task.get('result', {}).get('error', 'Generation failed')
|
||||||
|
error_type = task.get('result', {}).get('type', '')
|
||||||
|
# Retry on transient errors
|
||||||
|
if error_type in ('rate_limit', 'timeout', 'connection_error'):
|
||||||
|
self._schedule_retry(error_msg)
|
||||||
|
else:
|
||||||
|
self.write({
|
||||||
|
'state': 'error',
|
||||||
|
'error_message': error_msg,
|
||||||
|
})
|
||||||
|
# Notify user
|
||||||
|
if self.content_id and self.content_id.create_uid and self.content_id.create_uid.partner_id:
|
||||||
|
self.env['bus.bus']._sendone(
|
||||||
|
self.content_id.create_uid.partner_id,
|
||||||
|
'simple_notification',
|
||||||
|
{
|
||||||
|
'title': _("Image Generation Failed"),
|
||||||
|
'message': _("An image for '%s' could not be generated.") % (
|
||||||
|
self.content_id.generated_title or self.content_id.name),
|
||||||
|
'type': 'warning',
|
||||||
|
'sticky': False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._check_content_images_complete()
|
||||||
|
# else: still processing, do nothing
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
_logger.error("Image status poll error: %s", e)
|
||||||
|
|
||||||
|
def _process_completed_image(self, result):
|
||||||
|
"""Process a completed image generation result."""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
image_base64 = result.get('image_base64')
|
||||||
|
if not image_base64:
|
||||||
|
self.write({
|
||||||
|
'state': 'error',
|
||||||
|
'error_message': 'No image data received',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Parse size from aspect_ratio
|
||||||
|
size_parts = self.aspect_ratio.split('x')
|
||||||
|
width = int(size_parts[0]) if len(size_parts) == 2 else 1024
|
||||||
|
height = int(size_parts[1]) if len(size_parts) == 2 else 1024
|
||||||
|
|
||||||
|
# Generate alt text if not set
|
||||||
|
alt_text = self.alt_text
|
||||||
|
if not alt_text and self.content_id:
|
||||||
|
alt_text = f"{self.content_id.generated_title or self.content_id.name} - Image"
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'image': image_base64,
|
||||||
|
'image_filename': f"seo_image_{self.id}.png",
|
||||||
|
'revised_prompt': result.get('revised_prompt', ''),
|
||||||
|
'width': width,
|
||||||
|
'height': height,
|
||||||
|
'token_cost': result.get('cost', 0),
|
||||||
|
'state': 'done',
|
||||||
|
'error_message': False,
|
||||||
|
'alt_text': alt_text,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Notify user of individual image completion
|
||||||
|
if self.content_id and self.content_id.create_uid and self.content_id.create_uid.partner_id:
|
||||||
|
total = len(self.content_id.image_ids)
|
||||||
|
done = len(self.content_id.image_ids.filtered(lambda i: i.state == 'done'))
|
||||||
|
self.env['bus.bus']._sendone(
|
||||||
|
self.content_id.create_uid.partner_id,
|
||||||
|
'simple_notification',
|
||||||
|
{
|
||||||
|
'title': _("Image Generated"),
|
||||||
|
'message': _("Image %d/%d for '%s' is ready.") % (
|
||||||
|
done, total, self.content_id.generated_title or self.content_id.name),
|
||||||
|
'type': 'info',
|
||||||
|
'sticky': False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update content state if all images are done
|
||||||
|
self._check_content_images_complete()
|
||||||
|
|
||||||
|
def _schedule_retry(self, error_msg):
|
||||||
|
"""Schedule a retry with exponential backoff, or permanently fail."""
|
||||||
|
self.ensure_one()
|
||||||
|
backoff_minutes = [1, 5, 15]
|
||||||
|
|
||||||
|
if self.retry_count >= self.max_retries:
|
||||||
|
self.write({
|
||||||
|
'state': 'error',
|
||||||
|
'error_message': _("Permanently failed after %d retries. Last error: %s") % (
|
||||||
|
self.retry_count, error_msg),
|
||||||
|
'processing_since': False,
|
||||||
|
})
|
||||||
|
_logger.warning("Image %s permanently failed after %d retries", self.id, self.retry_count)
|
||||||
|
# Notify user of permanent failure
|
||||||
|
if self.content_id and self.content_id.create_uid and self.content_id.create_uid.partner_id:
|
||||||
|
self.env['bus.bus']._sendone(
|
||||||
|
self.content_id.create_uid.partner_id,
|
||||||
|
'simple_notification',
|
||||||
|
{
|
||||||
|
'title': _("Image Generation Failed"),
|
||||||
|
'message': _("An image for '%s' failed after %d retries.") % (
|
||||||
|
self.content_id.generated_title or self.content_id.name,
|
||||||
|
self.retry_count),
|
||||||
|
'type': 'warning',
|
||||||
|
'sticky': True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._check_content_images_complete()
|
||||||
|
return
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
delay = backoff_minutes[min(self.retry_count, len(backoff_minutes) - 1)]
|
||||||
|
next_retry = fields.Datetime.now() + timedelta(minutes=delay)
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'state': 'pending',
|
||||||
|
'retry_count': self.retry_count + 1,
|
||||||
|
'next_retry_at': next_retry,
|
||||||
|
'error_message': _("Retry %d/%d scheduled. Error: %s") % (
|
||||||
|
self.retry_count + 1, self.max_retries, error_msg),
|
||||||
|
'processing_since': False,
|
||||||
|
'task_id': False,
|
||||||
|
})
|
||||||
|
_logger.info("Image %s retry %d/%d scheduled at %s",
|
||||||
|
self.id, self.retry_count, self.max_retries, next_retry)
|
||||||
|
|
||||||
|
def _check_content_images_complete(self):
|
||||||
|
"""Check if all images for the content are complete.
|
||||||
|
|
||||||
|
Transitions content to 'review' when no images are pending/processing,
|
||||||
|
regardless of whether they are 'done' or 'error'.
|
||||||
|
"""
|
||||||
|
if not self.content_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
content = self.content_id
|
||||||
|
pending_images = content.image_ids.filtered(
|
||||||
|
lambda i: i.state in ('pending', 'processing')
|
||||||
|
)
|
||||||
|
|
||||||
|
if not pending_images and content.state == 'images_pending':
|
||||||
|
content.write({'state': 'review'})
|
||||||
|
# Send bus notification
|
||||||
|
if content.create_uid and content.create_uid.partner_id:
|
||||||
|
self.env['bus.bus']._sendone(
|
||||||
|
content.create_uid.partner_id,
|
||||||
|
'simple_notification',
|
||||||
|
{
|
||||||
|
'title': _("Images Ready!"),
|
||||||
|
'message': _("All images for '%s' are ready.") % (
|
||||||
|
content.generated_title or content.name),
|
||||||
|
'type': 'success',
|
||||||
|
'sticky': False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def cron_submit_pending_images(self):
|
||||||
|
"""Cron job to submit pending images for generation.
|
||||||
|
|
||||||
|
Also picks up images with pending retries.
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
now = fields.Datetime.now()
|
||||||
|
|
||||||
|
# Include images with pending retries (next_retry_at <= now)
|
||||||
|
pending = self.search([
|
||||||
|
'|',
|
||||||
|
'&', ('state', '=', 'pending'), ('next_retry_at', '=', False),
|
||||||
|
'&', ('state', '=', 'pending'), ('next_retry_at', '<=', now),
|
||||||
|
], limit=10)
|
||||||
|
|
||||||
|
for image in pending:
|
||||||
|
try:
|
||||||
|
image.write({'next_retry_at': False})
|
||||||
|
image.action_generate()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"Failed to submit image {image.id}: {e}")
|
||||||
|
image.write({
|
||||||
|
'state': 'error',
|
||||||
|
'error_message': str(e),
|
||||||
|
})
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def cron_poll_image_status(self):
|
||||||
|
"""Cron job to poll status of processing images.
|
||||||
|
|
||||||
|
Also handles stuck images (processing > 30 min) and retries on failure.
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
now = fields.Datetime.now()
|
||||||
|
|
||||||
|
# --- Detect stuck images (processing for > 30 minutes) ---
|
||||||
|
stuck_cutoff = now - timedelta(minutes=30)
|
||||||
|
stuck_images = self.search([
|
||||||
|
('state', '=', 'processing'),
|
||||||
|
('processing_since', '!=', False),
|
||||||
|
('processing_since', '<', stuck_cutoff),
|
||||||
|
], limit=10)
|
||||||
|
for img in stuck_images:
|
||||||
|
_logger.warning("Image %s stuck in processing since %s, scheduling retry",
|
||||||
|
img.id, img.processing_since)
|
||||||
|
img._schedule_retry(_("Task stuck for over 30 minutes"))
|
||||||
|
|
||||||
|
# --- Poll processing tasks ---
|
||||||
|
processing = self.search([
|
||||||
|
('state', '=', 'processing'),
|
||||||
|
('task_id', '!=', False),
|
||||||
|
], limit=20)
|
||||||
|
|
||||||
|
if not processing:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Batch poll all tasks - safely convert task_ids to int
|
||||||
|
task_ids = []
|
||||||
|
task_id_map = {} # Maps int task_id back to image
|
||||||
|
for img in processing:
|
||||||
|
if img.task_id:
|
||||||
|
try:
|
||||||
|
task_id_int = int(img.task_id)
|
||||||
|
task_ids.append(task_id_int)
|
||||||
|
task_id_map[str(task_id_int)] = img
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
_logger.warning("Invalid task_id format for image %s: %s", img.id, img.task_id)
|
||||||
|
img.write({
|
||||||
|
'state': 'error',
|
||||||
|
'error_message': f'Invalid task ID format: {img.task_id}',
|
||||||
|
})
|
||||||
|
|
||||||
|
if not task_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
base_url, api_key = processing[0]._get_api_config()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Odoo-Api-Key': api_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
f"{base_url.rstrip('/')}/api/tasks/",
|
||||||
|
headers=headers,
|
||||||
|
json={'task_ids': task_ids},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
_logger.error("Invalid JSON response from tasks API in cron: %s", response.text[:200])
|
||||||
|
return
|
||||||
|
|
||||||
|
tasks = data.get('tasks', []) if isinstance(data, dict) else data
|
||||||
|
|
||||||
|
# Create mapping of task_id to result
|
||||||
|
task_result_map = {str(t.get('id')): t for t in tasks if t.get('id')}
|
||||||
|
|
||||||
|
for task_id_str, image in task_id_map.items():
|
||||||
|
task = task_result_map.get(task_id_str)
|
||||||
|
if not task:
|
||||||
|
continue
|
||||||
|
|
||||||
|
status = task.get('status')
|
||||||
|
|
||||||
|
if status == 'completed':
|
||||||
|
result = task.get('result', {})
|
||||||
|
image._process_completed_image(result)
|
||||||
|
elif status == 'failed':
|
||||||
|
error_msg = task.get('result', {}).get('error', 'Generation failed')
|
||||||
|
image._schedule_retry(error_msg)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error("Image status poll cron error: %s", e)
|
||||||
|
|
||||||
|
@api.depends('original_size', 'optimized_size')
|
||||||
|
def _compute_size_reduction(self):
|
||||||
|
for record in self:
|
||||||
|
if record.original_size and record.optimized_size:
|
||||||
|
record.size_reduction = round(
|
||||||
|
(1 - record.optimized_size / record.original_size) * 100, 1
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
record.size_reduction = 0
|
||||||
|
|
||||||
|
def action_optimize(self):
|
||||||
|
"""Optimize image for web - compress and create WebP version."""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if not HAS_PILLOW:
|
||||||
|
raise UserError(_("Image optimization requires Pillow library. Please install it with: pip install Pillow"))
|
||||||
|
|
||||||
|
if not self.image:
|
||||||
|
raise UserError(_("No image to optimize."))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Decode original image
|
||||||
|
image_data = base64.b64decode(self.image)
|
||||||
|
original_size = len(image_data)
|
||||||
|
|
||||||
|
# Store original if not already stored
|
||||||
|
if not self.image_original:
|
||||||
|
self.image_original = self.image
|
||||||
|
|
||||||
|
# Open with Pillow
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
|
||||||
|
# Get actual dimensions
|
||||||
|
width, height = img.size
|
||||||
|
|
||||||
|
# Convert to RGB if necessary (for JPEG/WebP compatibility)
|
||||||
|
if img.mode in ('RGBA', 'P'):
|
||||||
|
# Create white background for transparency
|
||||||
|
background = Image.new('RGB', img.size, (255, 255, 255))
|
||||||
|
if img.mode == 'P':
|
||||||
|
img = img.convert('RGBA')
|
||||||
|
background.paste(img, mask=img.split()[3] if len(img.split()) > 3 else None)
|
||||||
|
img = background
|
||||||
|
elif img.mode != 'RGB':
|
||||||
|
img = img.convert('RGB')
|
||||||
|
|
||||||
|
quality = self.optimization_quality or 85
|
||||||
|
|
||||||
|
# Optimize as JPEG
|
||||||
|
jpeg_buffer = io.BytesIO()
|
||||||
|
img.save(jpeg_buffer, format='JPEG', quality=quality, optimize=True)
|
||||||
|
optimized_jpeg = jpeg_buffer.getvalue()
|
||||||
|
optimized_size = len(optimized_jpeg)
|
||||||
|
|
||||||
|
# Try to create WebP version (requires libwebp)
|
||||||
|
webp_data = None
|
||||||
|
webp_size = 0
|
||||||
|
try:
|
||||||
|
webp_buffer = io.BytesIO()
|
||||||
|
img.save(webp_buffer, format='WEBP', quality=quality)
|
||||||
|
webp_data = webp_buffer.getvalue()
|
||||||
|
webp_size = len(webp_data)
|
||||||
|
except Exception as webp_error:
|
||||||
|
_logger.warning(f"WebP conversion not available: {webp_error}")
|
||||||
|
|
||||||
|
# Update record
|
||||||
|
update_vals = {
|
||||||
|
'image': base64.b64encode(optimized_jpeg),
|
||||||
|
'image_filename': f"seo_image_{self.id}.jpg",
|
||||||
|
'original_size': original_size,
|
||||||
|
'optimized_size': optimized_size,
|
||||||
|
'webp_size': webp_size,
|
||||||
|
'width': width,
|
||||||
|
'height': height,
|
||||||
|
'is_optimized': True,
|
||||||
|
}
|
||||||
|
if webp_data:
|
||||||
|
update_vals['image_webp'] = base64.b64encode(webp_data)
|
||||||
|
|
||||||
|
self.write(update_vals)
|
||||||
|
|
||||||
|
_logger.info(
|
||||||
|
f"Image {self.id} optimized: {original_size} -> {optimized_size} bytes "
|
||||||
|
f"({round((1 - optimized_size/original_size) * 100, 1)}% reduction)"
|
||||||
|
+ (f", WebP: {webp_size} bytes" if webp_size else "")
|
||||||
|
)
|
||||||
|
|
||||||
|
message = _('Reduced from %s KB to %s KB (%.1f%% smaller)') % (
|
||||||
|
round(original_size / 1024, 1),
|
||||||
|
round(optimized_size / 1024, 1),
|
||||||
|
(1 - optimized_size / original_size) * 100,
|
||||||
|
)
|
||||||
|
if webp_size:
|
||||||
|
message += _('. WebP: %s KB') % round(webp_size / 1024, 1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"Image optimization failed: {e}")
|
||||||
|
raise UserError(_("Image optimization failed: %s") % str(e))
|
||||||
|
|
||||||
|
def action_restore_original(self):
|
||||||
|
"""Restore the original unoptimized image."""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if not self.image_original:
|
||||||
|
raise UserError(_("No original image stored."))
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'image': self.image_original,
|
||||||
|
'image_filename': f"seo_image_{self.id}.png",
|
||||||
|
'is_optimized': False,
|
||||||
|
'optimized_size': 0,
|
||||||
|
'image_webp': False,
|
||||||
|
'webp_size': 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
def action_auto_optimize_all(self):
|
||||||
|
"""Optimize all non-optimized images."""
|
||||||
|
images = self.search([
|
||||||
|
('state', '=', 'done'),
|
||||||
|
('is_optimized', '=', False),
|
||||||
|
('image', '!=', False),
|
||||||
|
])
|
||||||
|
|
||||||
|
optimized_count = 0
|
||||||
|
for image in images:
|
||||||
|
try:
|
||||||
|
image.action_optimize()
|
||||||
|
optimized_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"Failed to optimize image {image.id}: {e}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'display_notification',
|
||||||
|
'params': {
|
||||||
|
'title': _('Batch Optimization Complete'),
|
||||||
|
'message': _('%d images optimized.') % optimized_count,
|
||||||
|
'type': 'success',
|
||||||
|
'sticky': False,
|
||||||
|
'next': {'type': 'ir.actions.client', 'tag': 'soft_reload'},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.exceptions import UserError, ValidationError
|
||||||
|
from odoo.tools import html2plaintext
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MAX_REFERENCE_CONTENT_LENGTH = 50000
|
||||||
|
URL_FETCH_TIMEOUT = 30
|
||||||
|
|
||||||
|
# Optional dependencies for better HTML parsing
|
||||||
|
try:
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
HAS_BEAUTIFULSOUP = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_BEAUTIFULSOUP = False
|
||||||
|
_logger.info("BeautifulSoup not installed. Basic HTML parsing will be used for URL fetching.")
|
||||||
|
|
||||||
|
|
||||||
|
class OtkSeoReferenceTag(models.Model):
|
||||||
|
_name = 'otk.seo.reference.tag'
|
||||||
|
_description = 'SEO Reference Tag'
|
||||||
|
_order = 'name'
|
||||||
|
|
||||||
|
name = fields.Char('Tag Name', required=True, translate=True)
|
||||||
|
color = fields.Integer('Color Index', default=0)
|
||||||
|
|
||||||
|
@api.constrains('name')
|
||||||
|
def _check_name_unique(self):
|
||||||
|
for record in self:
|
||||||
|
if record.name:
|
||||||
|
duplicate = self.search([
|
||||||
|
('name', '=', record.name),
|
||||||
|
('id', '!=', record.id),
|
||||||
|
], limit=1)
|
||||||
|
if duplicate:
|
||||||
|
raise ValidationError(_('Tag name must be unique!'))
|
||||||
|
|
||||||
|
|
||||||
|
class OtkSeoReference(models.Model):
|
||||||
|
_name = 'otk.seo.reference'
|
||||||
|
_description = 'SEO Reference Library'
|
||||||
|
_order = 'sequence, name'
|
||||||
|
|
||||||
|
# === Identification ===
|
||||||
|
name = fields.Char('Reference Name', required=True, translate=True,
|
||||||
|
help='A descriptive name for this reference.')
|
||||||
|
|
||||||
|
reference_type = fields.Selection([
|
||||||
|
('internal_content', 'Internal Content'),
|
||||||
|
('url', 'URL'),
|
||||||
|
('text', 'Text'),
|
||||||
|
('file', 'File'),
|
||||||
|
], string='Type', required=True, default='text',
|
||||||
|
help='The type of reference material.')
|
||||||
|
|
||||||
|
# === Type-Specific Fields ===
|
||||||
|
internal_content_id = fields.Many2one('otk.seo.content', 'Internal Content',
|
||||||
|
domain="[('state', 'in', ['review', 'approved', 'published'])]",
|
||||||
|
help='Link to existing SEO content to use as reference.')
|
||||||
|
|
||||||
|
url = fields.Char('URL',
|
||||||
|
help='External URL to use as reference. Content will be fetched and stored locally.')
|
||||||
|
|
||||||
|
# === URL Fetching ===
|
||||||
|
url_fetched_content = fields.Text('Fetched Content',
|
||||||
|
help='Content extracted from the URL. Auto-fetched when URL is set.')
|
||||||
|
url_fetch_date = fields.Datetime('Last Fetched',
|
||||||
|
help='When the URL content was last fetched.')
|
||||||
|
url_fetch_status = fields.Selection([
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('fetching', 'Fetching'),
|
||||||
|
('success', 'Success'),
|
||||||
|
('error', 'Error'),
|
||||||
|
], string='Fetch Status', default='pending',
|
||||||
|
help='Status of the URL content fetch operation.')
|
||||||
|
url_fetch_error = fields.Char('Fetch Error',
|
||||||
|
help='Error message if URL fetch failed.')
|
||||||
|
url_content_type = fields.Char('Content Type',
|
||||||
|
help='MIME type of the fetched content.')
|
||||||
|
|
||||||
|
text_content = fields.Text('Text Content',
|
||||||
|
help='Raw text content to use as reference material.')
|
||||||
|
|
||||||
|
file = fields.Binary('File', attachment=True,
|
||||||
|
help='Upload a document (PDF, Word, or text file) to use as reference.')
|
||||||
|
file_name = fields.Char('File Name')
|
||||||
|
|
||||||
|
# === Organization ===
|
||||||
|
content_type = fields.Selection([
|
||||||
|
('blog_post', 'Blog Post'),
|
||||||
|
('product_desc', 'Product Description'),
|
||||||
|
('category_desc', 'Category Description'),
|
||||||
|
('landing_page', 'Landing Page Content'),
|
||||||
|
('all', 'All Types'),
|
||||||
|
], string='For Content Type', default='all',
|
||||||
|
help='Limit this reference to specific content types, or make available for all.')
|
||||||
|
|
||||||
|
tag_ids = fields.Many2many('otk.seo.reference.tag',
|
||||||
|
'otk_seo_reference_tag_rel', 'reference_id', 'tag_id',
|
||||||
|
string='Tags', help='Tags for organizing and filtering references.')
|
||||||
|
|
||||||
|
# === Metadata ===
|
||||||
|
description = fields.Text('Description', translate=True,
|
||||||
|
help='Brief description of what this reference contains and when to use it.')
|
||||||
|
|
||||||
|
active = fields.Boolean('Active', default=True)
|
||||||
|
sequence = fields.Integer('Sequence', default=10)
|
||||||
|
|
||||||
|
# === Computed ===
|
||||||
|
content_preview = fields.Text('Content Preview', compute='_compute_content_preview',
|
||||||
|
help='Preview of the extracted content.')
|
||||||
|
content_length = fields.Integer('Content Length', compute='_compute_content_preview',
|
||||||
|
help='Length of extractable content in characters.')
|
||||||
|
|
||||||
|
@api.depends('reference_type', 'internal_content_id', 'url', 'text_content', 'file',
|
||||||
|
'url_fetched_content', 'url_fetch_status')
|
||||||
|
def _compute_content_preview(self):
|
||||||
|
for record in self:
|
||||||
|
content = record._extract_content()
|
||||||
|
record.content_length = len(content)
|
||||||
|
if len(content) > 500:
|
||||||
|
record.content_preview = content[:500] + '...'
|
||||||
|
else:
|
||||||
|
record.content_preview = content
|
||||||
|
|
||||||
|
@api.constrains('reference_type', 'internal_content_id', 'url', 'text_content', 'file')
|
||||||
|
def _check_required_fields(self):
|
||||||
|
"""Ensure required fields are filled based on reference type."""
|
||||||
|
for record in self:
|
||||||
|
if record.reference_type == 'internal_content' and not record.internal_content_id:
|
||||||
|
raise UserError(_("Please select an internal content for this reference."))
|
||||||
|
elif record.reference_type == 'url' and not record.url:
|
||||||
|
raise UserError(_("Please provide a URL for this reference."))
|
||||||
|
elif record.reference_type == 'text' and not record.text_content:
|
||||||
|
raise UserError(_("Please provide text content for this reference."))
|
||||||
|
elif record.reference_type == 'file' and not record.file:
|
||||||
|
raise UserError(_("Please upload a file for this reference."))
|
||||||
|
|
||||||
|
@api.onchange('reference_type')
|
||||||
|
def _onchange_reference_type(self):
|
||||||
|
"""Clear irrelevant fields when type changes."""
|
||||||
|
if self.reference_type != 'internal_content':
|
||||||
|
self.internal_content_id = False
|
||||||
|
if self.reference_type != 'url':
|
||||||
|
self.url = False
|
||||||
|
self.url_fetched_content = False
|
||||||
|
self.url_fetch_date = False
|
||||||
|
self.url_fetch_status = 'pending'
|
||||||
|
self.url_fetch_error = False
|
||||||
|
self.url_content_type = False
|
||||||
|
if self.reference_type != 'text':
|
||||||
|
self.text_content = False
|
||||||
|
if self.reference_type != 'file':
|
||||||
|
self.file = False
|
||||||
|
self.file_name = False
|
||||||
|
|
||||||
|
def _extract_content(self):
|
||||||
|
"""Extract text content from reference based on type."""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if self.reference_type == 'url':
|
||||||
|
# Return fetched content if available, otherwise indicate pending
|
||||||
|
if self.url_fetched_content and self.url_fetch_status == 'success':
|
||||||
|
return self.url_fetched_content
|
||||||
|
elif self.url_fetch_status == 'error':
|
||||||
|
return f"[URL fetch error: {self.url_fetch_error or 'Unknown error'}]"
|
||||||
|
elif self.url_fetch_status == 'fetching':
|
||||||
|
return f"[Fetching content from: {self.url}...]"
|
||||||
|
else:
|
||||||
|
return f"[Content pending fetch from: {self.url}]"
|
||||||
|
|
||||||
|
elif self.reference_type == 'text':
|
||||||
|
return self.text_content or ''
|
||||||
|
|
||||||
|
elif self.reference_type == 'internal_content':
|
||||||
|
if self.internal_content_id and self.internal_content_id.generated_content:
|
||||||
|
return html2plaintext(self.internal_content_id.generated_content)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
elif self.reference_type == 'file':
|
||||||
|
return self._extract_file_content()
|
||||||
|
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def _extract_file_content(self):
|
||||||
|
"""Extract text content from uploaded file."""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if not self.file:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_data = base64.b64decode(self.file)
|
||||||
|
file_name_lower = (self.file_name or '').lower()
|
||||||
|
|
||||||
|
# Plain text
|
||||||
|
if file_name_lower.endswith('.txt'):
|
||||||
|
return file_data.decode('utf-8', errors='ignore')
|
||||||
|
|
||||||
|
# PDF
|
||||||
|
elif file_name_lower.endswith('.pdf'):
|
||||||
|
try:
|
||||||
|
import PyPDF2
|
||||||
|
from io import BytesIO
|
||||||
|
reader = PyPDF2.PdfReader(BytesIO(file_data))
|
||||||
|
text_parts = []
|
||||||
|
for page in reader.pages:
|
||||||
|
text = page.extract_text()
|
||||||
|
if text:
|
||||||
|
text_parts.append(text)
|
||||||
|
return '\n'.join(text_parts)
|
||||||
|
except ImportError:
|
||||||
|
_logger.warning("PyPDF2 not installed for PDF extraction")
|
||||||
|
return _('[PDF content - install PyPDF2 for extraction]')
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"PDF extraction error: {e}")
|
||||||
|
return _('[PDF extraction failed]')
|
||||||
|
|
||||||
|
# Word documents
|
||||||
|
elif file_name_lower.endswith(('.docx', '.doc')):
|
||||||
|
try:
|
||||||
|
from docx import Document
|
||||||
|
from io import BytesIO
|
||||||
|
doc = Document(BytesIO(file_data))
|
||||||
|
return '\n'.join(para.text for para in doc.paragraphs if para.text)
|
||||||
|
except ImportError:
|
||||||
|
_logger.warning("python-docx not installed for Word extraction")
|
||||||
|
return _('[Word content - install python-docx for extraction]')
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"Word extraction error: {e}")
|
||||||
|
return _('[Word extraction failed]')
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Try as plain text
|
||||||
|
return file_data.decode('utf-8', errors='ignore')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"File content extraction error: {e}")
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def get_api_payload(self):
|
||||||
|
"""Build API payload for this reference."""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
content = self._extract_content()
|
||||||
|
|
||||||
|
# Truncate if too long
|
||||||
|
if len(content) > MAX_REFERENCE_CONTENT_LENGTH:
|
||||||
|
content = content[:MAX_REFERENCE_CONTENT_LENGTH]
|
||||||
|
_logger.info(f"Reference {self.id} content truncated to {MAX_REFERENCE_CONTENT_LENGTH} chars")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': self.reference_type,
|
||||||
|
'title': self.name,
|
||||||
|
'content': content,
|
||||||
|
'source': 'internal' if self.reference_type == 'internal_content' else self.reference_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
# === URL Fetching Methods ===
|
||||||
|
|
||||||
|
def action_fetch_url_content(self):
|
||||||
|
"""Fetch content from URL and store it."""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if self.reference_type != 'url' or not self.url:
|
||||||
|
raise UserError(_("This reference is not a URL type or URL is empty."))
|
||||||
|
|
||||||
|
self.write({'url_fetch_status': 'fetching', 'url_fetch_error': False})
|
||||||
|
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (compatible; OToolKit/1.0; +https://otoolkit.app)',
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.5',
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.get(
|
||||||
|
self.url,
|
||||||
|
headers=headers,
|
||||||
|
timeout=URL_FETCH_TIMEOUT,
|
||||||
|
allow_redirects=True
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
content_type = response.headers.get('Content-Type', '')
|
||||||
|
|
||||||
|
if 'text/html' in content_type:
|
||||||
|
text_content = self._parse_html_content(response.text)
|
||||||
|
elif 'text/plain' in content_type:
|
||||||
|
text_content = response.text
|
||||||
|
elif 'application/json' in content_type:
|
||||||
|
text_content = response.text
|
||||||
|
else:
|
||||||
|
# Try to decode as text anyway
|
||||||
|
try:
|
||||||
|
text_content = response.text
|
||||||
|
except Exception:
|
||||||
|
text_content = f"[Unsupported content type: {content_type}]"
|
||||||
|
|
||||||
|
# Clean and truncate if needed
|
||||||
|
text_content = self._clean_text_content(text_content)
|
||||||
|
|
||||||
|
if len(text_content) > MAX_REFERENCE_CONTENT_LENGTH:
|
||||||
|
text_content = text_content[:MAX_REFERENCE_CONTENT_LENGTH]
|
||||||
|
_logger.info(f"URL content truncated to {MAX_REFERENCE_CONTENT_LENGTH} chars for reference {self.id}")
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'url_fetched_content': text_content,
|
||||||
|
'url_fetch_date': fields.Datetime.now(),
|
||||||
|
'url_fetch_status': 'success',
|
||||||
|
'url_fetch_error': False,
|
||||||
|
'url_content_type': content_type[:100] if content_type else False,
|
||||||
|
})
|
||||||
|
|
||||||
|
_logger.info(f"Successfully fetched {len(text_content)} chars from {self.url} for reference {self.id}")
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
self._set_url_fetch_error(_("Request timed out after %d seconds.") % URL_FETCH_TIMEOUT)
|
||||||
|
except requests.exceptions.TooManyRedirects:
|
||||||
|
self._set_url_fetch_error(_("Too many redirects."))
|
||||||
|
except requests.exceptions.SSLError as e:
|
||||||
|
self._set_url_fetch_error(_("SSL Error: %s") % str(e)[:100])
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
self._set_url_fetch_error(_("Could not connect to URL."))
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
self._set_url_fetch_error(_("HTTP Error %s") % e.response.status_code if e.response else str(e)[:100])
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"URL fetch error for reference {self.id}: {e}")
|
||||||
|
self._set_url_fetch_error(str(e)[:200])
|
||||||
|
|
||||||
|
def _set_url_fetch_error(self, error_message):
|
||||||
|
"""Set URL fetch error state."""
|
||||||
|
self.write({
|
||||||
|
'url_fetch_status': 'error',
|
||||||
|
'url_fetch_error': error_message[:255] if error_message else 'Unknown error',
|
||||||
|
'url_fetch_date': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
def _parse_html_content(self, html_text):
|
||||||
|
"""Parse HTML and extract readable text content."""
|
||||||
|
if HAS_BEAUTIFULSOUP:
|
||||||
|
return self._parse_html_with_beautifulsoup(html_text)
|
||||||
|
else:
|
||||||
|
return self._parse_html_basic(html_text)
|
||||||
|
|
||||||
|
def _parse_html_with_beautifulsoup(self, html_text):
|
||||||
|
"""Parse HTML using BeautifulSoup for better extraction."""
|
||||||
|
soup = BeautifulSoup(html_text, 'html.parser')
|
||||||
|
|
||||||
|
# Remove script, style, nav, footer, header, aside elements
|
||||||
|
for element in soup(['script', 'style', 'nav', 'footer', 'header', 'aside', 'noscript', 'iframe']):
|
||||||
|
element.decompose()
|
||||||
|
|
||||||
|
# Try to find main content area
|
||||||
|
main_content = (
|
||||||
|
soup.find('main') or
|
||||||
|
soup.find('article') or
|
||||||
|
soup.find('div', {'class': re.compile(r'content|main|post|article', re.I)}) or
|
||||||
|
soup.find('div', {'id': re.compile(r'content|main|post|article', re.I)}) or
|
||||||
|
soup.find('body')
|
||||||
|
)
|
||||||
|
|
||||||
|
if main_content:
|
||||||
|
# Get text with some structure preserved
|
||||||
|
text_parts = []
|
||||||
|
for element in main_content.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'td', 'th', 'blockquote']):
|
||||||
|
text = element.get_text(strip=True)
|
||||||
|
if text:
|
||||||
|
# Add markers for headings
|
||||||
|
if element.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']:
|
||||||
|
text_parts.append(f"\n## {text}\n")
|
||||||
|
elif element.name == 'li':
|
||||||
|
text_parts.append(f"• {text}")
|
||||||
|
else:
|
||||||
|
text_parts.append(text)
|
||||||
|
|
||||||
|
return '\n'.join(text_parts)
|
||||||
|
else:
|
||||||
|
return soup.get_text(separator='\n', strip=True)
|
||||||
|
|
||||||
|
def _parse_html_basic(self, html_text):
|
||||||
|
"""Basic HTML parsing without BeautifulSoup."""
|
||||||
|
# Remove script and style content
|
||||||
|
html_text = re.sub(r'<script[^>]*>.*?</script>', '', html_text, flags=re.DOTALL | re.IGNORECASE)
|
||||||
|
html_text = re.sub(r'<style[^>]*>.*?</style>', '', html_text, flags=re.DOTALL | re.IGNORECASE)
|
||||||
|
|
||||||
|
# Convert some tags to text markers
|
||||||
|
html_text = re.sub(r'<h[1-6][^>]*>(.*?)</h[1-6]>', r'\n## \1\n', html_text, flags=re.DOTALL | re.IGNORECASE)
|
||||||
|
html_text = re.sub(r'<li[^>]*>(.*?)</li>', r'• \1\n', html_text, flags=re.DOTALL | re.IGNORECASE)
|
||||||
|
html_text = re.sub(r'<p[^>]*>(.*?)</p>', r'\1\n\n', html_text, flags=re.DOTALL | re.IGNORECASE)
|
||||||
|
html_text = re.sub(r'<br\s*/?>', '\n', html_text, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
# Remove remaining tags
|
||||||
|
html_text = re.sub(r'<[^>]+>', '', html_text)
|
||||||
|
|
||||||
|
# Decode HTML entities
|
||||||
|
html_text = html_text.replace(' ', ' ')
|
||||||
|
html_text = html_text.replace('&', '&')
|
||||||
|
html_text = html_text.replace('<', '<')
|
||||||
|
html_text = html_text.replace('>', '>')
|
||||||
|
html_text = html_text.replace('"', '"')
|
||||||
|
|
||||||
|
return html_text
|
||||||
|
|
||||||
|
def _clean_text_content(self, text):
|
||||||
|
"""Clean extracted text content."""
|
||||||
|
if not text:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
# Normalize whitespace
|
||||||
|
text = re.sub(r'[ \t]+', ' ', text)
|
||||||
|
# Remove excessive newlines
|
||||||
|
text = re.sub(r'\n{3,}', '\n\n', text)
|
||||||
|
# Strip each line
|
||||||
|
lines = [line.strip() for line in text.split('\n')]
|
||||||
|
# Remove empty lines at start/end
|
||||||
|
text = '\n'.join(lines).strip()
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
# === Auto-fetch on Create/Write ===
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
"""Auto-fetch URL content for new URL references."""
|
||||||
|
records = super().create(vals_list)
|
||||||
|
for record in records:
|
||||||
|
if record.reference_type == 'url' and record.url:
|
||||||
|
try:
|
||||||
|
record.action_fetch_url_content()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning(f"Auto-fetch failed for new reference {record.id}: {e}")
|
||||||
|
return records
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
"""Auto-fetch URL content when URL changes."""
|
||||||
|
result = super().write(vals)
|
||||||
|
|
||||||
|
# Re-fetch if URL changed for URL type references
|
||||||
|
if 'url' in vals and vals.get('url'):
|
||||||
|
for record in self:
|
||||||
|
if record.reference_type == 'url':
|
||||||
|
try:
|
||||||
|
record.action_fetch_url_content()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning(f"Auto-fetch failed for reference {record.id}: {e}")
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
from odoo import api, fields, models, _
|
||||||
|
|
||||||
|
|
||||||
|
class OtkSeoTemplate(models.Model):
|
||||||
|
_name = 'otk.seo.template'
|
||||||
|
_description = 'SEO Content Template'
|
||||||
|
_order = 'sequence, name'
|
||||||
|
|
||||||
|
name = fields.Char('Template Name', required=True, translate=True,
|
||||||
|
help='A descriptive name for this template (e.g., "How-To Guide", "Product Review").')
|
||||||
|
sequence = fields.Integer('Sequence', default=10,
|
||||||
|
help='Order in which templates appear in selection lists. Lower numbers appear first.')
|
||||||
|
active = fields.Boolean('Active', default=True,
|
||||||
|
help='If unchecked, this template will not be available for selection.')
|
||||||
|
|
||||||
|
content_type = fields.Selection([
|
||||||
|
('blog_post', 'Blog Post'),
|
||||||
|
('product_desc', 'Product Description'),
|
||||||
|
('category_desc', 'Category Description'),
|
||||||
|
('landing_page', 'Landing Page Content'),
|
||||||
|
], string='Content Type', required=True, default='blog_post',
|
||||||
|
help='The type of content this template is designed for. Templates only appear for matching content types.')
|
||||||
|
|
||||||
|
description = fields.Text('Description', translate=True,
|
||||||
|
help='Explanation of when and how to use this template. Shown to users when selecting a template.')
|
||||||
|
|
||||||
|
# === Prompt Configuration ===
|
||||||
|
system_prompt = fields.Text('System Prompt',
|
||||||
|
help='Background instructions for the AI defining its role, expertise, and constraints. This shapes the overall writing style and approach.')
|
||||||
|
|
||||||
|
content_prompt_template = fields.Text('Content Prompt Template',
|
||||||
|
help='The main prompt template sent to the AI. Use placeholders: {keywords}, {topic}, {product_name}, {tone}, {word_count}, {language}. These will be replaced with actual values.')
|
||||||
|
|
||||||
|
title_prompt_template = fields.Text('Title Prompt Template',
|
||||||
|
help='Template for generating the content title. Use placeholders to customize based on keywords or topic.')
|
||||||
|
|
||||||
|
meta_prompt_template = fields.Text('Meta Description Prompt Template',
|
||||||
|
help='Template for generating the SEO meta description. Should produce text between 120-160 characters.')
|
||||||
|
|
||||||
|
# === Defaults ===
|
||||||
|
default_tone = fields.Selection([
|
||||||
|
('professional', 'Professional'),
|
||||||
|
('casual', 'Casual'),
|
||||||
|
('technical', 'Technical'),
|
||||||
|
('persuasive', 'Persuasive'),
|
||||||
|
('conversational', 'Conversational'),
|
||||||
|
], string='Default Tone', default='professional',
|
||||||
|
help='The default writing tone when using this template. Users can override this when generating content.')
|
||||||
|
|
||||||
|
default_word_count = fields.Selection([
|
||||||
|
('short', 'Short (300-500 words)'),
|
||||||
|
('medium', 'Medium (500-1000 words)'),
|
||||||
|
('long', 'Long (1000-2000 words)'),
|
||||||
|
], string='Default Length', default='medium',
|
||||||
|
help='The default target word count when using this template. Users can override this when generating content.')
|
||||||
|
|
||||||
|
# === Image Generation ===
|
||||||
|
include_images = fields.Boolean('Include Image Generation', default=True,
|
||||||
|
help='If enabled, image generation will be included by default when using this template.')
|
||||||
|
default_image_count = fields.Integer('Default Image Count', default=1,
|
||||||
|
help='Default number of images to generate when using this template.')
|
||||||
|
image_prompt_template = fields.Text('Image Prompt Template',
|
||||||
|
help='Template for generating image prompts. Use placeholders: {title}, {keywords}, {section}. The AI will use this to create relevant images.')
|
||||||
|
default_image_style = fields.Selection([
|
||||||
|
('photorealistic', 'Photorealistic'),
|
||||||
|
('illustration', 'Illustration'),
|
||||||
|
('digital_art', 'Digital Art'),
|
||||||
|
('watercolor', 'Watercolor'),
|
||||||
|
('sketch', 'Sketch'),
|
||||||
|
('3d_render', '3D Render'),
|
||||||
|
], string='Default Image Style', default='photorealistic',
|
||||||
|
help='The default visual style for generated images when using this template.')
|
||||||
|
|
||||||
|
# === Formatting Options ===
|
||||||
|
include_toc = fields.Boolean('Include Table of Contents',
|
||||||
|
help='If enabled, the AI will generate a table of contents at the beginning of the content. Best for longer articles.')
|
||||||
|
include_faq = fields.Boolean('Include FAQ Section',
|
||||||
|
help='If enabled, the AI will add a FAQ section at the end. Good for SEO and covering common questions.')
|
||||||
|
include_cta = fields.Boolean('Include Call-to-Action',
|
||||||
|
help='If enabled, the AI will include a call-to-action at the end of the content.')
|
||||||
|
header_structure = fields.Selection([
|
||||||
|
('h2_only', 'H2 Headers Only'),
|
||||||
|
('h2_h3', 'H2 and H3 Headers'),
|
||||||
|
('h2_h3_h4', 'H2, H3, and H4 Headers'),
|
||||||
|
], string='Header Structure', default='h2_h3',
|
||||||
|
help='Heading hierarchy to use in the content. H2/H3 is recommended for most content; simpler structure for short content, deeper nesting for comprehensive guides.')
|
||||||
|
|
||||||
|
# === Reference Library ===
|
||||||
|
reference_ids = fields.Many2many('otk.seo.reference',
|
||||||
|
'otk_seo_template_reference_rel', 'template_id', 'reference_id',
|
||||||
|
string='Default References',
|
||||||
|
domain="['|', ('content_type', '=', content_type), ('content_type', '=', 'all')]",
|
||||||
|
help='References to include by default when using this template. Users can add or remove references when generating content.')
|
||||||
|
|
||||||
|
# === Statistics ===
|
||||||
|
usage_count = fields.Integer('Times Used', compute='_compute_usage_count',
|
||||||
|
help='Number of content items created using this template.')
|
||||||
|
|
||||||
|
def _compute_usage_count(self):
|
||||||
|
if not self.ids:
|
||||||
|
return
|
||||||
|
data = self.env['otk.seo.content']._read_group(
|
||||||
|
[('template_id', 'in', self.ids)],
|
||||||
|
['template_id'],
|
||||||
|
['__count'],
|
||||||
|
)
|
||||||
|
counts = {template.id: count for template, count in data}
|
||||||
|
for record in self:
|
||||||
|
record.usage_count = counts.get(record.id, 0)
|
||||||
|
|
||||||
|
def action_view_contents(self):
|
||||||
|
"""View contents created with this template."""
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'otk.seo.content',
|
||||||
|
'view_mode': 'list,form',
|
||||||
|
'domain': [('template_id', '=', self.id)],
|
||||||
|
'name': _('Contents using %s') % self.name,
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_otk_seo_content_user,otk.seo.content.user,model_otk_seo_content,group_seo_content_user,1,1,1,0
|
||||||
|
access_otk_seo_content_manager,otk.seo.content.manager,model_otk_seo_content,group_seo_content_manager,1,1,1,1
|
||||||
|
access_otk_seo_content_image_user,otk.seo.content.image.user,model_otk_seo_content_image,group_seo_content_user,1,1,1,0
|
||||||
|
access_otk_seo_content_image_manager,otk.seo.content.image.manager,model_otk_seo_content_image,group_seo_content_manager,1,1,1,1
|
||||||
|
access_otk_seo_template_user,otk.seo.template.user,model_otk_seo_template,group_seo_content_user,1,0,0,0
|
||||||
|
access_otk_seo_template_manager,otk.seo.template.manager,model_otk_seo_template,group_seo_content_manager,1,1,1,1
|
||||||
|
access_otk_seo_content_version_user,otk.seo.content.version.user,model_otk_seo_content_version,group_seo_content_user,1,0,0,0
|
||||||
|
access_otk_seo_content_version_manager,otk.seo.content.version.manager,model_otk_seo_content_version,group_seo_content_manager,1,1,1,1
|
||||||
|
access_otk_seo_content_generate_wizard_user,otk.seo.content.generate.wizard.user,model_otk_seo_content_generate_wizard,group_seo_content_user,1,1,1,1
|
||||||
|
access_otk_seo_brand_voice_user,otk.seo.brand.voice.user,model_otk_seo_brand_voice,group_seo_content_user,1,0,0,0
|
||||||
|
access_otk_seo_brand_voice_manager,otk.seo.brand.voice.manager,model_otk_seo_brand_voice,group_seo_content_manager,1,1,1,1
|
||||||
|
access_otk_seo_content_batch_user,otk.seo.content.batch.user,model_otk_seo_content_batch,group_seo_content_user,1,1,1,0
|
||||||
|
access_otk_seo_content_batch_manager,otk.seo.content.batch.manager,model_otk_seo_content_batch,group_seo_content_manager,1,1,1,1
|
||||||
|
access_otk_seo_content_batch_item_user,otk.seo.content.batch.item.user,model_otk_seo_content_batch_item,group_seo_content_user,1,1,1,0
|
||||||
|
access_otk_seo_content_batch_item_manager,otk.seo.content.batch.item.manager,model_otk_seo_content_batch_item,group_seo_content_manager,1,1,1,1
|
||||||
|
access_otk_seo_reference_user,otk.seo.reference.user,model_otk_seo_reference,group_seo_content_user,1,0,0,0
|
||||||
|
access_otk_seo_reference_manager,otk.seo.reference.manager,model_otk_seo_reference,group_seo_content_manager,1,1,1,1
|
||||||
|
access_otk_seo_reference_tag_user,otk.seo.reference.tag.user,model_otk_seo_reference_tag,group_seo_content_user,1,0,0,0
|
||||||
|
access_otk_seo_reference_tag_manager,otk.seo.reference.tag.manager,model_otk_seo_reference_tag,group_seo_content_manager,1,1,1,1
|
||||||
|
access_otk_seo_content_idea_user,otk.seo.content.idea.user,model_otk_seo_content_idea,group_seo_content_user,1,1,1,0
|
||||||
|
access_otk_seo_content_idea_manager,otk.seo.content.idea.manager,model_otk_seo_content_idea,group_seo_content_manager,1,1,1,1
|
||||||
|
@@ -0,0 +1,153 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="0">
|
||||||
|
|
||||||
|
<!-- Privilege Definition -->
|
||||||
|
<record model="res.groups.privilege" id="res_groups_privilege_seo_content">
|
||||||
|
<field name="name">SEO Content</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="category_id" ref="otoolkit_seo_content.module_category_otoolkit_seo"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Security Groups -->
|
||||||
|
<record id="group_seo_content_user" model="res.groups">
|
||||||
|
<field name="name">User</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="comment">Can create and manage their own SEO content</field>
|
||||||
|
<field name="privilege_id" ref="res_groups_privilege_seo_content"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_seo_content_manager" model="res.groups">
|
||||||
|
<field name="name">Administrator</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="comment">Can manage all SEO content and configure templates</field>
|
||||||
|
<field name="privilege_id" ref="res_groups_privilege_seo_content"/>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_seo_content_user'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
<data noupdate="1">
|
||||||
|
|
||||||
|
<!-- Record Rules -->
|
||||||
|
|
||||||
|
<!-- SEO Content: Users can see their own content -->
|
||||||
|
<record model="ir.rule" id="seo_content_user_rule">
|
||||||
|
<field name="name">SEO Content: User own records</field>
|
||||||
|
<field name="model_id" search="[('model','=','otk.seo.content')]" model="ir.model"/>
|
||||||
|
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_seo_content_user'))]"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- SEO Content: Managers can see all content -->
|
||||||
|
<record model="ir.rule" id="seo_content_manager_rule">
|
||||||
|
<field name="name">SEO Content: Manager all records</field>
|
||||||
|
<field name="model_id" search="[('model','=','otk.seo.content')]" model="ir.model"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_seo_content_manager'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- SEO Content Image: Users follow parent content access -->
|
||||||
|
<record model="ir.rule" id="seo_content_image_user_rule">
|
||||||
|
<field name="name">SEO Content Image: User own records</field>
|
||||||
|
<field name="model_id" search="[('model','=','otk.seo.content.image')]" model="ir.model"/>
|
||||||
|
<field name="domain_force">[('content_id.create_uid', '=', user.id)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_seo_content_user'))]"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record model="ir.rule" id="seo_content_image_manager_rule">
|
||||||
|
<field name="name">SEO Content Image: Manager all records</field>
|
||||||
|
<field name="model_id" search="[('model','=','otk.seo.content.image')]" model="ir.model"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_seo_content_manager'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- SEO Content Batch: Users can see their own batches -->
|
||||||
|
<record model="ir.rule" id="seo_content_batch_user_rule">
|
||||||
|
<field name="name">SEO Content Batch: User own records</field>
|
||||||
|
<field name="model_id" search="[('model','=','otk.seo.content.batch')]" model="ir.model"/>
|
||||||
|
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_seo_content_user'))]"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record model="ir.rule" id="seo_content_batch_manager_rule">
|
||||||
|
<field name="name">SEO Content Batch: Manager all records</field>
|
||||||
|
<field name="model_id" search="[('model','=','otk.seo.content.batch')]" model="ir.model"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_seo_content_manager'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- SEO Content Batch Items: Follow parent batch access -->
|
||||||
|
<record model="ir.rule" id="seo_content_batch_item_user_rule">
|
||||||
|
<field name="name">SEO Content Batch Item: User own records</field>
|
||||||
|
<field name="model_id" search="[('model','=','otk.seo.content.batch.item')]" model="ir.model"/>
|
||||||
|
<field name="domain_force">[('batch_id.create_uid', '=', user.id)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_seo_content_user'))]"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record model="ir.rule" id="seo_content_batch_item_manager_rule">
|
||||||
|
<field name="name">SEO Content Batch Item: Manager all records</field>
|
||||||
|
<field name="model_id" search="[('model','=','otk.seo.content.batch.item')]" model="ir.model"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_seo_content_manager'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- SEO Content Versions: Follow parent content access -->
|
||||||
|
<record model="ir.rule" id="seo_content_version_user_rule">
|
||||||
|
<field name="name">SEO Content Version: User own records</field>
|
||||||
|
<field name="model_id" search="[('model','=','otk.seo.content.version')]" model="ir.model"/>
|
||||||
|
<field name="domain_force">[('content_id.create_uid', '=', user.id)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_seo_content_user'))]"/>
|
||||||
|
<field name="perm_write" eval="False"/>
|
||||||
|
<field name="perm_create" eval="False"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record model="ir.rule" id="seo_content_version_manager_rule">
|
||||||
|
<field name="name">SEO Content Version: Manager all records</field>
|
||||||
|
<field name="model_id" search="[('model','=','otk.seo.content.version')]" model="ir.model"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_seo_content_manager'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- SEO Reference Library: All users can read -->
|
||||||
|
<record model="ir.rule" id="seo_reference_user_rule">
|
||||||
|
<field name="name">SEO Reference: User read all</field>
|
||||||
|
<field name="model_id" search="[('model','=','otk.seo.reference')]" model="ir.model"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_seo_content_user'))]"/>
|
||||||
|
<field name="perm_write" eval="False"/>
|
||||||
|
<field name="perm_create" eval="False"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record model="ir.rule" id="seo_reference_manager_rule">
|
||||||
|
<field name="name">SEO Reference: Manager full access</field>
|
||||||
|
<field name="model_id" search="[('model','=','otk.seo.reference')]" model="ir.model"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_seo_content_manager'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- SEO Reference Tags: All users can read -->
|
||||||
|
<record model="ir.rule" id="seo_reference_tag_user_rule">
|
||||||
|
<field name="name">SEO Reference Tag: User read all</field>
|
||||||
|
<field name="model_id" search="[('model','=','otk.seo.reference.tag')]" model="ir.model"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_seo_content_user'))]"/>
|
||||||
|
<field name="perm_write" eval="False"/>
|
||||||
|
<field name="perm_create" eval="False"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record model="ir.rule" id="seo_reference_tag_manager_rule">
|
||||||
|
<field name="name">SEO Reference Tag: Manager full access</field>
|
||||||
|
<field name="model_id" search="[('model','=','otk.seo.reference.tag')]" model="ir.model"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_seo_content_manager'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
After Width: | Height: | Size: 6.9 MiB |
|
After Width: | Height: | Size: 5.6 MiB |
|
After Width: | Height: | Size: 9.4 MiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 589 KiB |
|
After Width: | Height: | Size: 531 KiB |
@@ -0,0 +1,235 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080">
|
||||||
|
<defs>
|
||||||
|
<!-- Background gradient -->
|
||||||
|
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#7C3AED"/>
|
||||||
|
<stop offset="100%" style="stop-color:#5B21B6"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Document gradient -->
|
||||||
|
<linearGradient id="docGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#FFFFFF"/>
|
||||||
|
<stop offset="100%" style="stop-color:#F3F4F6"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Glow effect for AI -->
|
||||||
|
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="8" result="coloredBlur"/>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="coloredBlur"/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
<!-- Soft shadow -->
|
||||||
|
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="8" stdDeviation="12" flood-color="#000000" flood-opacity="0.25"/>
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
<!-- Arrow gradient -->
|
||||||
|
<linearGradient id="arrowGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style="stop-color:#FBBF24"/>
|
||||||
|
<stop offset="100%" style="stop-color:#F59E0B"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background -->
|
||||||
|
<rect width="1920" height="1080" fill="url(#bgGradient)"/>
|
||||||
|
|
||||||
|
<!-- Subtle pattern overlay -->
|
||||||
|
<g opacity="0.05">
|
||||||
|
<circle cx="200" cy="200" r="300" fill="#FFFFFF"/>
|
||||||
|
<circle cx="1700" cy="900" r="400" fill="#FFFFFF"/>
|
||||||
|
<circle cx="1000" cy="100" r="200" fill="#FFFFFF"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- ==================== LEFT SIDE: INPUT ==================== -->
|
||||||
|
|
||||||
|
<!-- Input document/card -->
|
||||||
|
<g filter="url(#shadow)">
|
||||||
|
<rect x="120" y="280" width="380" height="480" rx="20" fill="url(#docGradient)"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Input header -->
|
||||||
|
<rect x="120" y="280" width="380" height="70" rx="20" fill="#7C3AED"/>
|
||||||
|
<rect x="120" y="320" width="380" height="30" fill="#7C3AED"/>
|
||||||
|
<text x="310" y="325" text-anchor="middle" fill="#FFFFFF" font-family="system-ui, -apple-system, sans-serif" font-size="24" font-weight="600">INPUT</text>
|
||||||
|
|
||||||
|
<!-- Keywords label -->
|
||||||
|
<text x="160" y="400" fill="#6B7280" font-family="system-ui, sans-serif" font-size="18" font-weight="500">Keywords</text>
|
||||||
|
|
||||||
|
<!-- Keyword tags -->
|
||||||
|
<rect x="160" y="415" width="100" height="32" rx="16" fill="#EDE9FE"/>
|
||||||
|
<text x="210" y="437" text-anchor="middle" fill="#7C3AED" font-family="system-ui, sans-serif" font-size="14" font-weight="500">SEO</text>
|
||||||
|
|
||||||
|
<rect x="270" y="415" width="120" height="32" rx="16" fill="#EDE9FE"/>
|
||||||
|
<text x="330" y="437" text-anchor="middle" fill="#7C3AED" font-family="system-ui, sans-serif" font-size="14" font-weight="500">Content</text>
|
||||||
|
|
||||||
|
<rect x="160" y="455" width="140" height="32" rx="16" fill="#EDE9FE"/>
|
||||||
|
<text x="230" y="477" text-anchor="middle" fill="#7C3AED" font-family="system-ui, sans-serif" font-size="14" font-weight="500">Marketing</text>
|
||||||
|
|
||||||
|
<!-- Topic label -->
|
||||||
|
<text x="160" y="530" fill="#6B7280" font-family="system-ui, sans-serif" font-size="18" font-weight="500">Topic</text>
|
||||||
|
|
||||||
|
<!-- Topic input box -->
|
||||||
|
<rect x="160" y="545" width="300" height="45" rx="8" fill="#F3F4F6" stroke="#E5E7EB" stroke-width="2"/>
|
||||||
|
<text x="180" y="575" fill="#374151" font-family="system-ui, sans-serif" font-size="16">"Product Launch Blog"</text>
|
||||||
|
|
||||||
|
<!-- Tone label -->
|
||||||
|
<text x="160" y="630" fill="#6B7280" font-family="system-ui, sans-serif" font-size="18" font-weight="500">Tone</text>
|
||||||
|
|
||||||
|
<!-- Tone selector -->
|
||||||
|
<rect x="160" y="645" width="140" height="40" rx="8" fill="#7C3AED"/>
|
||||||
|
<text x="230" y="672" text-anchor="middle" fill="#FFFFFF" font-family="system-ui, sans-serif" font-size="15" font-weight="500">Professional</text>
|
||||||
|
|
||||||
|
<rect x="310" y="645" width="140" height="40" rx="8" fill="#F3F4F6" stroke="#E5E7EB" stroke-width="2"/>
|
||||||
|
<text x="380" y="672" text-anchor="middle" fill="#6B7280" font-family="system-ui, sans-serif" font-size="15">Casual</text>
|
||||||
|
|
||||||
|
<!-- ==================== CENTER: AI TRANSFORMATION ==================== -->
|
||||||
|
|
||||||
|
<!-- Flow arrow left -->
|
||||||
|
<path d="M540 520 L620 520" stroke="url(#arrowGradient)" stroke-width="6" stroke-linecap="round" fill="none"/>
|
||||||
|
<polygon points="630,520 610,508 610,532" fill="#FBBF24"/>
|
||||||
|
|
||||||
|
<!-- AI Brain/Magic circle -->
|
||||||
|
<g filter="url(#glow)">
|
||||||
|
<circle cx="810" cy="520" r="140" fill="#FFFFFF" opacity="0.15"/>
|
||||||
|
<circle cx="810" cy="520" r="110" fill="#FFFFFF" opacity="0.25"/>
|
||||||
|
<circle cx="810" cy="520" r="80" fill="#FFFFFF"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- AI Icon - Brain with circuit pattern -->
|
||||||
|
<g transform="translate(810, 520)">
|
||||||
|
<!-- Brain outline -->
|
||||||
|
<path d="M-35 -10 C-35 -35 -15 -50 5 -50 C25 -50 40 -35 40 -15 C55 -15 60 5 50 20 C60 35 50 55 30 55 C30 55 20 60 0 60 C-20 60 -30 55 -30 55 C-50 55 -60 35 -50 20 C-60 5 -55 -15 -35 -10 Z"
|
||||||
|
fill="none" stroke="#7C3AED" stroke-width="4"/>
|
||||||
|
|
||||||
|
<!-- Circuit dots -->
|
||||||
|
<circle cx="-15" cy="-25" r="6" fill="#7C3AED"/>
|
||||||
|
<circle cx="15" cy="-20" r="6" fill="#A78BFA"/>
|
||||||
|
<circle cx="-5" cy="5" r="8" fill="#7C3AED"/>
|
||||||
|
<circle cx="25" cy="10" r="6" fill="#A78BFA"/>
|
||||||
|
<circle cx="-20" cy="25" r="6" fill="#7C3AED"/>
|
||||||
|
<circle cx="10" cy="35" r="6" fill="#A78BFA"/>
|
||||||
|
|
||||||
|
<!-- Circuit lines -->
|
||||||
|
<path d="M-15 -25 L-5 5 M15 -20 L-5 5 M-5 5 L25 10 M-5 5 L-20 25 M-5 5 L10 35"
|
||||||
|
stroke="#7C3AED" stroke-width="2" opacity="0.6"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- AI Sparkles around brain -->
|
||||||
|
<g filter="url(#glow)">
|
||||||
|
<!-- Large sparkle top right -->
|
||||||
|
<g transform="translate(920, 400)">
|
||||||
|
<path d="M0 -25 L7 -7 L25 0 L7 7 L0 25 L-7 7 L-25 0 L-7 -7 Z" fill="#FBBF24"/>
|
||||||
|
<circle cx="0" cy="0" r="6" fill="#FFFFFF"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Medium sparkle top left -->
|
||||||
|
<g transform="translate(700, 420)">
|
||||||
|
<path d="M0 -18 L5 -5 L18 0 L5 5 L0 18 L-5 5 L-18 0 L-5 -5 Z" fill="#FBBF24"/>
|
||||||
|
<circle cx="0" cy="0" r="4" fill="#FFFFFF"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Small sparkle bottom -->
|
||||||
|
<g transform="translate(850, 650)">
|
||||||
|
<path d="M0 -15 L4 -4 L15 0 L4 4 L0 15 L-4 4 L-15 0 L-4 -4 Z" fill="#FBBF24" opacity="0.9"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Tiny sparkles -->
|
||||||
|
<g transform="translate(740, 600) scale(0.6)">
|
||||||
|
<path d="M0 -15 L4 -4 L15 0 L4 4 L0 15 L-4 4 L-15 0 L-4 -4 Z" fill="#FBBF24" opacity="0.7"/>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(900, 480) scale(0.5)">
|
||||||
|
<path d="M0 -15 L4 -4 L15 0 L4 4 L0 15 L-4 4 L-15 0 L-4 -4 Z" fill="#FBBF24" opacity="0.8"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- "AI" label -->
|
||||||
|
<text x="810" y="720" text-anchor="middle" fill="#FFFFFF" font-family="system-ui, sans-serif" font-size="28" font-weight="700" opacity="0.9">AI-Powered</text>
|
||||||
|
|
||||||
|
<!-- Flow arrow right -->
|
||||||
|
<path d="M1000 520 L1080 520" stroke="url(#arrowGradient)" stroke-width="6" stroke-linecap="round" fill="none"/>
|
||||||
|
<polygon points="1090,520 1070,508 1070,532" fill="#FBBF24"/>
|
||||||
|
|
||||||
|
<!-- ==================== RIGHT SIDE: OUTPUT ==================== -->
|
||||||
|
|
||||||
|
<!-- Output document -->
|
||||||
|
<g filter="url(#shadow)">
|
||||||
|
<rect x="1140" y="200" width="420" height="560" rx="20" fill="url(#docGradient)"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Output header -->
|
||||||
|
<rect x="1140" y="200" width="420" height="70" rx="20" fill="#10B981"/>
|
||||||
|
<rect x="1140" y="240" width="420" height="30" fill="#10B981"/>
|
||||||
|
<text x="1350" y="245" text-anchor="middle" fill="#FFFFFF" font-family="system-ui, -apple-system, sans-serif" font-size="24" font-weight="600">SEO OPTIMIZED</text>
|
||||||
|
|
||||||
|
<!-- Generated title -->
|
||||||
|
<rect x="1180" y="300" width="340" height="20" rx="4" fill="#7C3AED"/>
|
||||||
|
<rect x="1180" y="330" width="280" height="14" rx="3" fill="#9CA3AF"/>
|
||||||
|
|
||||||
|
<!-- Content preview lines -->
|
||||||
|
<rect x="1180" y="370" width="340" height="10" rx="3" fill="#D1D5DB"/>
|
||||||
|
<rect x="1180" y="390" width="320" height="10" rx="3" fill="#D1D5DB"/>
|
||||||
|
<rect x="1180" y="410" width="340" height="10" rx="3" fill="#D1D5DB"/>
|
||||||
|
<rect x="1180" y="430" width="280" height="10" rx="3" fill="#D1D5DB"/>
|
||||||
|
|
||||||
|
<!-- Subheading -->
|
||||||
|
<rect x="1180" y="465" width="200" height="16" rx="4" fill="#7C3AED" opacity="0.7"/>
|
||||||
|
|
||||||
|
<!-- More content lines -->
|
||||||
|
<rect x="1180" y="495" width="340" height="10" rx="3" fill="#D1D5DB"/>
|
||||||
|
<rect x="1180" y="515" width="300" height="10" rx="3" fill="#D1D5DB"/>
|
||||||
|
<rect x="1180" y="535" width="340" height="10" rx="3" fill="#D1D5DB"/>
|
||||||
|
|
||||||
|
<!-- SEO Score Circle -->
|
||||||
|
<g transform="translate(1470, 670)">
|
||||||
|
<circle cx="0" cy="0" r="70" fill="#FFFFFF" stroke="#10B981" stroke-width="8"/>
|
||||||
|
<text x="0" y="10" text-anchor="middle" fill="#10B981" font-family="system-ui, sans-serif" font-size="42" font-weight="700">92</text>
|
||||||
|
<text x="0" y="35" text-anchor="middle" fill="#6B7280" font-family="system-ui, sans-serif" font-size="14">SEO Score</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Mini chart bars -->
|
||||||
|
<g transform="translate(1200, 620)">
|
||||||
|
<rect x="0" y="40" width="25" height="60" rx="4" fill="#7C3AED"/>
|
||||||
|
<rect x="35" y="25" width="25" height="75" rx="4" fill="#A78BFA"/>
|
||||||
|
<rect x="70" y="10" width="25" height="90" rx="4" fill="#10B981"/>
|
||||||
|
<rect x="105" y="30" width="25" height="70" rx="4" fill="#7C3AED"/>
|
||||||
|
|
||||||
|
<!-- Trend line -->
|
||||||
|
<path d="M12 35 L47 20 L82 5 L117 25" stroke="#FBBF24" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||||
|
<circle cx="82" cy="5" r="5" fill="#FBBF24"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Checkmarks for SEO items -->
|
||||||
|
<g transform="translate(1180, 580)">
|
||||||
|
<circle cx="10" cy="10" r="10" fill="#10B981"/>
|
||||||
|
<path d="M5 10 L9 14 L16 6" stroke="#FFFFFF" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<text x="30" y="15" fill="#374151" font-family="system-ui, sans-serif" font-size="13">Meta optimized</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- ==================== BOTTOM: TITLE & TAGLINE ==================== -->
|
||||||
|
|
||||||
|
<!-- Module name -->
|
||||||
|
<text x="960" y="920" text-anchor="middle" fill="#FFFFFF" font-family="system-ui, -apple-system, BlinkMacSystemFont, sans-serif" font-size="56" font-weight="700">SEO Content Generator</text>
|
||||||
|
|
||||||
|
<!-- Tagline -->
|
||||||
|
<text x="960" y="980" text-anchor="middle" fill="#FFFFFF" font-family="system-ui, -apple-system, sans-serif" font-size="28" font-weight="400" opacity="0.9">AI-Powered Content Creation & Optimization for Odoo</text>
|
||||||
|
|
||||||
|
<!-- Decorative bottom sparkles -->
|
||||||
|
<g transform="translate(550, 950) scale(0.4)">
|
||||||
|
<path d="M0 -20 L5 -5 L20 0 L5 5 L0 20 L-5 5 L-20 0 L-5 -5 Z" fill="#FBBF24" opacity="0.6"/>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(1370, 940) scale(0.35)">
|
||||||
|
<path d="M0 -20 L5 -5 L20 0 L5 5 L0 20 L-5 5 L-20 0 L-5 -5 Z" fill="#FBBF24" opacity="0.6"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Corner accent sparkles -->
|
||||||
|
<g transform="translate(80, 100)">
|
||||||
|
<path d="M0 -30 L8 -8 L30 0 L8 8 L0 30 L-8 8 L-30 0 L-8 -8 Z" fill="#FBBF24" opacity="0.5"/>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(1840, 980)">
|
||||||
|
<path d="M0 -25 L7 -7 L25 0 L7 7 L0 25 L-7 7 L-25 0 L-7 -7 Z" fill="#FBBF24" opacity="0.5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,71 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#7C3AED;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#5B21B6;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="docGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#FFFFFF;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#F3F4F6;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background rounded square -->
|
||||||
|
<rect x="20" y="20" width="472" height="472" rx="80" ry="80" fill="url(#bgGradient)"/>
|
||||||
|
|
||||||
|
<!-- Document/Paper -->
|
||||||
|
<path d="M140 100 L300 100 L360 160 L360 400 C360 415 348 427 333 427 L167 427 C152 427 140 415 140 400 L140 100 Z"
|
||||||
|
fill="url(#docGradient)"
|
||||||
|
stroke="#E5E7EB"
|
||||||
|
stroke-width="2"/>
|
||||||
|
|
||||||
|
<!-- Document fold -->
|
||||||
|
<path d="M300 100 L300 160 L360 160"
|
||||||
|
fill="#E5E7EB"
|
||||||
|
stroke="#D1D5DB"
|
||||||
|
stroke-width="2"/>
|
||||||
|
|
||||||
|
<!-- Text lines on document -->
|
||||||
|
<rect x="170" y="185" width="160" height="12" rx="6" fill="#7C3AED" opacity="0.8"/>
|
||||||
|
<rect x="170" y="215" width="130" height="10" rx="5" fill="#9CA3AF"/>
|
||||||
|
<rect x="170" y="240" width="150" height="10" rx="5" fill="#9CA3AF"/>
|
||||||
|
<rect x="170" y="265" width="120" height="10" rx="5" fill="#9CA3AF"/>
|
||||||
|
<rect x="170" y="300" width="160" height="12" rx="6" fill="#7C3AED" opacity="0.6"/>
|
||||||
|
<rect x="170" y="330" width="140" height="10" rx="5" fill="#9CA3AF"/>
|
||||||
|
<rect x="170" y="355" width="155" height="10" rx="5" fill="#9CA3AF"/>
|
||||||
|
|
||||||
|
<!-- SEO Chart/Analytics circle -->
|
||||||
|
<circle cx="380" cy="340" r="85" fill="#FFFFFF" stroke="#7C3AED" stroke-width="4"/>
|
||||||
|
|
||||||
|
<!-- Chart bars inside circle -->
|
||||||
|
<rect x="330" y="355" width="20" height="45" rx="4" fill="#7C3AED"/>
|
||||||
|
<rect x="360" y="325" width="20" height="75" rx="4" fill="#A78BFA"/>
|
||||||
|
<rect x="390" y="305" width="20" height="95" rx="4" fill="#7C3AED"/>
|
||||||
|
<rect x="420" y="340" width="20" height="60" rx="4" fill="#A78BFA"/>
|
||||||
|
|
||||||
|
<!-- Upward trend arrow -->
|
||||||
|
<path d="M335 320 L405 280 L405 300 L425 300 L425 265 L390 265 L390 285 L340 315"
|
||||||
|
fill="none"
|
||||||
|
stroke="#10B981"
|
||||||
|
stroke-width="6"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
<polygon points="425,265 440,280 425,280" fill="#10B981"/>
|
||||||
|
|
||||||
|
<!-- AI Sparkle/Star (top right) -->
|
||||||
|
<g transform="translate(420, 120)">
|
||||||
|
<path d="M0 -30 L8 -8 L30 0 L8 8 L0 30 L-8 8 L-30 0 L-8 -8 Z"
|
||||||
|
fill="#FBBF24"/>
|
||||||
|
<circle cx="0" cy="0" r="8" fill="#FFFFFF"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Small sparkles -->
|
||||||
|
<g transform="translate(100, 150) scale(0.5)">
|
||||||
|
<path d="M0 -20 L5 -5 L20 0 L5 5 L0 20 L-5 5 L-20 0 L-5 -5 Z"
|
||||||
|
fill="#FBBF24" opacity="0.8"/>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(400, 430) scale(0.4)">
|
||||||
|
<path d="M0 -20 L5 -5 L20 0 L5 5 L0 20 L-5 5 L-20 0 L-5 -5 Z"
|
||||||
|
fill="#FBBF24" opacity="0.8"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,233 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.4.1/dist/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="row position-relative pt-lg-3 px-lg-5 pb-lg-3 p-md-4 p-3 mx-auto d-flex align-items-center" style="color:#333333; font-weight:500; font-size:16px; width:95%">
|
||||||
|
<section class="oe_container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="col-md-12 text-center" style="margin-top: 10px;">
|
||||||
|
<a target="_blank" href="https://otoolkit.app">
|
||||||
|
<h2>O'Toolkit</h2>
|
||||||
|
</a>
|
||||||
|
<p>
|
||||||
|
<a href="https://otoolkit.app" target="_blank">Website: otoolkit.app</a><br>
|
||||||
|
Email: <a href="mailto:contact@otoolkit.app">contact@otoolkit.app</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product Title -->
|
||||||
|
<div class="col-md-12 text-center" style="margin-top: 30px;">
|
||||||
|
<a target="_blank" href="https://otoolkit.app/application/seo-content">
|
||||||
|
<h2>SEO Content Generator</h2>
|
||||||
|
</a>
|
||||||
|
<p>The <strong>SEO Content Generator</strong> is an AI-powered content creation suite for Odoo that helps you generate, optimize, and manage SEO-friendly blog posts, product descriptions, and marketing content directly within your ERP. Powered by advanced AI models, it creates high-quality content with built-in SEO analysis, generates matching images, and seamlessly publishes to your Odoo website blog.</p>
|
||||||
|
<p>Complete documentation available at <a href="https://otoolkit.app/application/seo-content">https://otoolkit.app/application/seo-content</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- How it works -->
|
||||||
|
<div class="col-md-12 text-center" style="margin-top: 30px;text-align: left !important;">
|
||||||
|
<h2 style="text-align: center !important">
|
||||||
|
<b>How it works</b>
|
||||||
|
</h2>
|
||||||
|
<div class="row text-dark">
|
||||||
|
<div class="col-lg-8 col-sm-12 mt-5 mb-5">
|
||||||
|
<img src="assets/generate-wizard.webp" class="shadow" style="width: 100%; border-radius: 15px;" alt="SEO Content generation wizard in Odoo">
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 col-sm-12 mt-5 mb-5" style="display: flex; align-items: center;">
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
<b>Generate in one click</b>
|
||||||
|
</h3>
|
||||||
|
<p class="mt-4">Enter your keywords or topic, select your content type and tone, and let AI generate a complete SEO-optimized article with title, content, meta tags, and teaser.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="d-lg-none">
|
||||||
|
|
||||||
|
<div class="col-lg-4 col-sm-12 mt-5 mb-5" style="display: flex; align-items: center;">
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
<b>Real-time SEO analysis</b>
|
||||||
|
</h3>
|
||||||
|
<p class="mt-4">Get instant feedback on your content's SEO performance with a detailed score breakdown and actionable improvement suggestions.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-8 col-sm-12 mt-5 mb-5">
|
||||||
|
<img src="assets/seo-analysis.webp" class="shadow" style="width: 100%; border-radius: 15px;" alt="SEO analysis with score and recommendations">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI Image Generation -->
|
||||||
|
<div class="col-md-12 text-center" style="margin-top: 30px;text-align: left !important;">
|
||||||
|
<h2 style="text-align: center !important">
|
||||||
|
<b>AI-Powered Image Generation</b>
|
||||||
|
</h2>
|
||||||
|
<div class="row text-dark">
|
||||||
|
<div class="col-lg-8 col-sm-12 mt-5 mb-5">
|
||||||
|
<img src="assets/image-generation.gif" class="shadow" style="width: 100%; border-radius: 15px;" alt="AI image generation with multiple styles">
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 col-sm-12 mt-5 mb-5" style="display: flex; align-items: center;">
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
<b>Matching visuals</b>
|
||||||
|
</h3>
|
||||||
|
<p class="mt-4">Automatically generate images that match your content. Choose from multiple styles: photorealistic, illustration, digital art, watercolor, sketch, or 3D render.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="d-lg-none">
|
||||||
|
|
||||||
|
<div class="col-lg-4 col-sm-12 mt-5 mb-5" style="display: flex; align-items: center;">
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
<b>Content-aware prompts</b>
|
||||||
|
</h3>
|
||||||
|
<p class="mt-4">Images are generated based on your content's title, sections, and keywords, ensuring they perfectly complement your article.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-8 col-sm-12 mt-5 mb-5">
|
||||||
|
<img src="assets/image-styles.gif" class="shadow" style="width: 100%; border-radius: 15px;" alt="Different image generation styles">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Features -->
|
||||||
|
<div class="col-md-12 text-center" style="margin-top: 30px;text-align: left !important;">
|
||||||
|
<h2 style="text-align: center !important">
|
||||||
|
<b>Powerful Features</b>
|
||||||
|
</h2>
|
||||||
|
<div class="row text-dark">
|
||||||
|
<div class="col-lg-8 col-sm-12 mt-5 mb-5">
|
||||||
|
<img src="assets/batch-processing.gif" class="shadow" style="width: 100%; border-radius: 15px;" alt="Batch content generation">
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 col-sm-12 mt-5 mb-5" style="display: flex; align-items: center;">
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
<b>Batch generation</b>
|
||||||
|
</h3>
|
||||||
|
<p class="mt-4">Generate hundreds of product descriptions or blog posts at once. Import from CSV, product categories, or keyword lists.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="d-lg-none">
|
||||||
|
|
||||||
|
<div class="col-lg-4 col-sm-12 mt-5 mb-5" style="display: flex; align-items: center;">
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
<b>Ideas Queue & Scheduling</b>
|
||||||
|
</h3>
|
||||||
|
<p class="mt-4">Plan your content calendar with the Ideas Queue. Schedule content for future generation and even auto-publish to your blog.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-8 col-sm-12 mt-5 mb-5">
|
||||||
|
<img src="assets/ideas-queue.webp" class="shadow" style="width: 100%; border-radius: 15px;" alt="Ideas queue and content scheduling">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="d-lg-none">
|
||||||
|
|
||||||
|
<div class="col-lg-8 col-sm-12 mt-5 mb-5">
|
||||||
|
<img src="assets/blog-integration.gif" class="shadow" style="width: 100%; border-radius: 15px;" alt="Blog post creation from SEO content">
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 col-sm-12 mt-5 mb-5" style="display: flex; align-items: center;">
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
<b>One-click blog publishing</b>
|
||||||
|
</h3>
|
||||||
|
<p class="mt-4">Create blog posts directly from your generated content with cover images, meta tags, and SEO-friendly URLs already configured.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="d-lg-none">
|
||||||
|
|
||||||
|
<div class="col-lg-4 col-sm-12 mt-5 mb-5" style="display: flex; align-items: center;">
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
<b>Templates & Brand Voice</b>
|
||||||
|
</h3>
|
||||||
|
<p class="mt-4">Create reusable templates with custom prompts and brand voice guidelines to maintain consistency across all your content.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-8 col-sm-12 mt-5 mb-5">
|
||||||
|
<img src="assets/templates.gif" class="shadow" style="width: 100%; border-radius: 15px;" alt="Content templates and brand voice">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="d-lg-none">
|
||||||
|
|
||||||
|
<div class="col-lg-8 col-sm-12 mt-5 mb-5">
|
||||||
|
<img src="assets/references.gif" class="shadow" style="width: 100%; border-radius: 15px;" alt="URL reference fetching">
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 col-sm-12 mt-5 mb-5" style="display: flex; align-items: center;">
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
<b>Reference materials</b>
|
||||||
|
</h3>
|
||||||
|
<p class="mt-4">Add URLs as references and the AI will fetch and analyze their content to create more informed, accurate articles.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Multi-language -->
|
||||||
|
<div class="col-md-12 text-center" style="margin-top: 30px;text-align: left !important;">
|
||||||
|
<h2 style="text-align: center !important">
|
||||||
|
<b>Multi-Language Support</b>
|
||||||
|
</h2>
|
||||||
|
<div class="row text-dark">
|
||||||
|
<div class="col-lg-4 col-sm-12 mt-5 mb-5" style="display: flex; align-items: center;">
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
<b>Generate in any language</b>
|
||||||
|
</h3>
|
||||||
|
<p class="mt-4">Create content in any language activated in your Odoo instance. Combined with O'Toolkit Auto Translate, automatically translate to all your website languages.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-8 col-sm-12 mt-5 mb-5">
|
||||||
|
<img src="assets/multi-language.webp" class="shadow" style="width: 100%; border-radius: 15px;" alt="Multi-language content generation">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Types -->
|
||||||
|
<div class="col-md-12 text-center" style="margin-top: 30px;">
|
||||||
|
<h2><b>Content Types</b></h2>
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-md-4 mb-4">
|
||||||
|
<div class="p-4" style="background-color: #f8f9fa; border-radius: 15px; height: 100%;">
|
||||||
|
<h4 style="color: rgb(124, 58, 237);">Blog Posts</h4>
|
||||||
|
<p>Full-length articles optimized for search engines with proper heading structure, meta tags, and engaging content.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-4">
|
||||||
|
<div class="p-4" style="background-color: #f8f9fa; border-radius: 15px; height: 100%;">
|
||||||
|
<h4 style="color: rgb(124, 58, 237);">Product Descriptions</h4>
|
||||||
|
<p>Compelling product descriptions that highlight features, benefits, and SEO keywords. Apply directly to products.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-4">
|
||||||
|
<div class="p-4" style="background-color: #f8f9fa; border-radius: 15px; height: 100%;">
|
||||||
|
<h4 style="color: rgb(124, 58, 237);">Landing Pages</h4>
|
||||||
|
<p>Conversion-focused content for landing pages, category descriptions, and marketing campaigns.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA Footer -->
|
||||||
|
<div class="col-md-12 text-center mt-5 p-4" style="background-color: #f8f9fa; border-radius: 15px; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);">
|
||||||
|
<h3 style="color: rgb(124, 58, 237);">Ready to supercharge your content creation?</h3>
|
||||||
|
<p class="mt-3" style="color: #333;">Start generating SEO-optimized content in minutes. Visit our website or contact us for more information.</p>
|
||||||
|
<p>
|
||||||
|
<span href="https://otoolkit.app" class="btn otoolkit-open-site" style="background-color: rgb(124, 58, 237); color: #fff; border-radius: 5px; padding: 10px 20px; text-decoration: none;">Visit Our Website</span>
|
||||||
|
<a href="mailto:contact@otoolkit.app" class="btn" style="background-color: #fff; color: rgb(124, 58, 237); border: 2px solid rgb(124, 58, 237); border-radius: 5px; padding: 10px 20px; text-decoration: none;">Email Us</a>
|
||||||
|
</p>
|
||||||
|
<a href="https://otoolkit.app">https://otoolkit.app</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
/* O'Toolkit SEO Content Generator - Custom Styles */
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SEO Score Badge Styling
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.o_seo_score_badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 50px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_score_excellent {
|
||||||
|
background-color: #28a745;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_score_good {
|
||||||
|
background-color: #5cb85c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_score_average {
|
||||||
|
background-color: #ffc107;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_score_poor {
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Content Generation States
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.o_seo_content_state {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_content_state .o_state_generating {
|
||||||
|
color: #17a2b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_content_state .o_state_generated {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_content_state .o_state_failed {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generating animation */
|
||||||
|
.o_seo_generating_spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid #17a2b8;
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: transparent;
|
||||||
|
animation: seo-spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes seo-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Content Preview Styling
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.o_seo_content_preview {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_content_preview h1,
|
||||||
|
.o_seo_content_preview h2,
|
||||||
|
.o_seo_content_preview h3,
|
||||||
|
.o_seo_content_preview h4 {
|
||||||
|
color: #343a40;
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_content_preview h1:first-child,
|
||||||
|
.o_seo_content_preview h2:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_content_preview p {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_content_preview ul,
|
||||||
|
.o_seo_content_preview ol {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_content_preview li {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Image Gallery Styling
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.o_seo_image_gallery {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_image_card {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #fff;
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_image_card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_image_card img {
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_image_card .o_image_info {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_image_card .o_image_state {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_image_card .o_image_state_pending {
|
||||||
|
background-color: #ffc107;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_image_card .o_image_state_generating {
|
||||||
|
background-color: #17a2b8;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_image_card .o_image_state_completed {
|
||||||
|
background-color: #28a745;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_image_card .o_image_state_failed {
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image placeholder for pending/generating */
|
||||||
|
.o_seo_image_placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Kanban Card Styling
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.o_kanban_record .o_seo_content_kanban {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_content_kanban .o_kanban_title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_content_kanban .o_kanban_meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_content_kanban .o_kanban_tag {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_content_kanban .o_kanban_progress {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Template Selection Styling
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.o_seo_template_selector {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_template_card {
|
||||||
|
border: 2px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_template_card:hover {
|
||||||
|
border-color: #007bff;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_template_card.selected {
|
||||||
|
border-color: #007bff;
|
||||||
|
background-color: #e7f1ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_template_card .o_template_name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_template_card .o_template_desc {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #6c757d;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_template_card .o_template_meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Wizard Styling
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.o_seo_wizard_step {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_wizard_step .o_step_header {
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #343a40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_wizard_step .o_step_description {
|
||||||
|
color: #6c757d;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keyword input styling */
|
||||||
|
.o_seo_keyword_input {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #fff;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_keyword_tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_keyword_tag .o_remove {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_keyword_tag .o_remove:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Version History Styling
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.o_seo_version_list {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_version_item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_version_item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_version_item .o_version_info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_version_item .o_version_number {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_version_item .o_version_date {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_version_item .o_version_actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Cost Estimation Panel
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.o_seo_cost_panel {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_cost_panel .o_cost_header {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #343a40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_cost_panel .o_cost_row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_cost_panel .o_cost_total {
|
||||||
|
border-top: 1px solid #dee2e6;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SEO Analysis Panel
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.o_seo_analysis_panel {
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_analysis_item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #f1f3f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_analysis_item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_analysis_item .o_check_icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_analysis_item .o_check_pass {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_analysis_item .o_check_warn {
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_analysis_item .o_check_fail {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_analysis_item .o_check_text {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_analysis_item .o_check_suggestion {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Wizard Step Indicator
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.otk_wizard_steps {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otk_step_circle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otk_step_circle.otk_step_active {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.otk_step_circle.otk_step_done {
|
||||||
|
background-color: #28a745;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otk_step_circle.otk_step_upcoming {
|
||||||
|
background-color: #dee2e6;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Inline SEO Panel
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.o_seo_inline_panel {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_inline_panel .o_meta_indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Meta Length Indicators
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.o_meta_length_optimal {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_meta_length_short {
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_meta_length_long {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Diff View Styling
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.o_seo_diff_view {
|
||||||
|
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.5;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_diff_view .o_diff_header {
|
||||||
|
color: #6c757d;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 4px;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_diff_view .o_diff_section {
|
||||||
|
color: #6f42c1;
|
||||||
|
padding: 2px 4px;
|
||||||
|
background-color: #f3e8ff;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_diff_view .o_diff_add {
|
||||||
|
color: #155724;
|
||||||
|
background-color: #d4edda;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-left: 3px solid #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_diff_view .o_diff_remove {
|
||||||
|
color: #721c24;
|
||||||
|
background-color: #f8d7da;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-left: 3px solid #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_diff_view .o_diff_context {
|
||||||
|
color: #495057;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Responsive Adjustments
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.o_seo_image_gallery {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_template_selector {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_content_preview {
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_step_indicator {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_step_chevron {
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_inline_panel .row {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_seo_diff_view {
|
||||||
|
max-height: 300px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
|
class SeoDashboard extends Component {
|
||||||
|
static template = "otoolkit_seo_content.SeoDashboard";
|
||||||
|
static props = ["*"];
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.orm = useService("orm");
|
||||||
|
this.action = useService("action");
|
||||||
|
this.state = useState({
|
||||||
|
data: null,
|
||||||
|
loading: true,
|
||||||
|
});
|
||||||
|
onWillStart(async () => {
|
||||||
|
await this.loadDashboardData();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadDashboardData() {
|
||||||
|
this.state.loading = true;
|
||||||
|
try {
|
||||||
|
this.state.data = await this.orm.call(
|
||||||
|
"otk.seo.content",
|
||||||
|
"get_dashboard_data",
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load dashboard data:", e);
|
||||||
|
this.state.data = null;
|
||||||
|
}
|
||||||
|
this.state.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getScoreColor(score) {
|
||||||
|
if (score >= 70) return "success";
|
||||||
|
if (score >= 40) return "warning";
|
||||||
|
return "danger";
|
||||||
|
}
|
||||||
|
|
||||||
|
getStateLabel(state) {
|
||||||
|
const labels = {
|
||||||
|
draft: "Draft",
|
||||||
|
generating: "Generating",
|
||||||
|
images_pending: "Images Pending",
|
||||||
|
review: "Review",
|
||||||
|
approved: "Approved",
|
||||||
|
published: "Published",
|
||||||
|
archived: "Archived",
|
||||||
|
};
|
||||||
|
return labels[state] || state;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTypeLabel(type) {
|
||||||
|
const labels = {
|
||||||
|
blog_post: "Blog Post",
|
||||||
|
product_desc: "Product Desc",
|
||||||
|
category_desc: "Category Desc",
|
||||||
|
landing_page: "Landing Page",
|
||||||
|
social_post: "Social Post",
|
||||||
|
};
|
||||||
|
return labels[type] || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStateColor(state) {
|
||||||
|
const colors = {
|
||||||
|
draft: "#6c757d",
|
||||||
|
generating: "#17a2b8",
|
||||||
|
images_pending: "#ffc107",
|
||||||
|
review: "#007bff",
|
||||||
|
approved: "#28a745",
|
||||||
|
published: "#20c997",
|
||||||
|
archived: "#adb5bd",
|
||||||
|
};
|
||||||
|
return colors[state] || "#6c757d";
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickTotalContent() {
|
||||||
|
this.action.doAction({
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
name: "All Content",
|
||||||
|
res_model: "otk.seo.content",
|
||||||
|
view_mode: "list,form",
|
||||||
|
views: [[false, "list"], [false, "form"]],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickStaleContent() {
|
||||||
|
this.action.doAction({
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
name: "Stale Content",
|
||||||
|
res_model: "otk.seo.content",
|
||||||
|
view_mode: "list,form",
|
||||||
|
views: [[false, "list"], [false, "form"]],
|
||||||
|
domain: [["is_stale", "=", true]],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickState(state) {
|
||||||
|
this.action.doAction({
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
name: this.getStateLabel(state),
|
||||||
|
res_model: "otk.seo.content",
|
||||||
|
view_mode: "list,form",
|
||||||
|
views: [[false, "list"], [false, "form"]],
|
||||||
|
domain: [["state", "=", state]],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickRecent(contentId) {
|
||||||
|
this.action.doAction({
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
res_model: "otk.seo.content",
|
||||||
|
res_id: contentId,
|
||||||
|
view_mode: "form",
|
||||||
|
views: [[false, "form"]],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.category("actions").add("seo_content_dashboard", SeoDashboard);
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="otoolkit_seo_content.SeoDashboard">
|
||||||
|
<div class="o_action">
|
||||||
|
<div class="container-fluid py-3">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2 class="mb-0">
|
||||||
|
<i class="fa fa-line-chart me-2"/>SEO Content Dashboard
|
||||||
|
</h2>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" t-on-click="loadDashboardData">
|
||||||
|
<i class="fa fa-refresh me-1"/>Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div t-if="state.loading" class="text-center py-5">
|
||||||
|
<i class="fa fa-spinner fa-spin fa-3x text-primary"/>
|
||||||
|
<p class="mt-3 text-muted">Loading dashboard data...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<t t-if="!state.loading and state.data">
|
||||||
|
<!-- Row 1: KPI Cards -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<!-- Total Content -->
|
||||||
|
<div class="col-md-6 col-lg-3">
|
||||||
|
<div class="card h-100 border-0 shadow-sm" style="cursor:pointer" t-on-click="onClickTotalContent">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="text-muted small text-uppercase mb-1">Total Content</div>
|
||||||
|
<div class="display-5 fw-bold text-primary" t-out="state.data.totalContent"/>
|
||||||
|
<div class="text-muted small mt-1">pieces created</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Avg SEO Score -->
|
||||||
|
<div class="col-md-6 col-lg-3">
|
||||||
|
<div class="card h-100 border-0 shadow-sm">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="text-muted small text-uppercase mb-1">Avg SEO Score</div>
|
||||||
|
<div class="display-5 fw-bold">
|
||||||
|
<span t-attf-class="text-#{getScoreColor(state.data.avgSeoScore)}" t-out="state.data.avgSeoScore"/>
|
||||||
|
</div>
|
||||||
|
<div class="progress mt-2" style="height: 6px;">
|
||||||
|
<div class="progress-bar"
|
||||||
|
t-attf-class="bg-#{getScoreColor(state.data.avgSeoScore)}"
|
||||||
|
role="progressbar"
|
||||||
|
t-attf-style="width: #{state.data.avgSeoScore}%"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tokens Used -->
|
||||||
|
<div class="col-md-6 col-lg-3">
|
||||||
|
<div class="card h-100 border-0 shadow-sm">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="text-muted small text-uppercase mb-1">Tokens Used</div>
|
||||||
|
<div class="display-5 fw-bold text-info" t-out="state.data.totalTokens"/>
|
||||||
|
<div class="text-muted small mt-1">total consumed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Stale Content -->
|
||||||
|
<div class="col-md-6 col-lg-3">
|
||||||
|
<div class="card h-100 border-0 shadow-sm" style="cursor:pointer" t-on-click="onClickStaleContent">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="text-muted small text-uppercase mb-1">Needs Refresh</div>
|
||||||
|
<div t-attf-class="display-5 fw-bold #{state.data.staleCount > 0 ? 'text-warning' : 'text-success'}"
|
||||||
|
t-out="state.data.staleCount"/>
|
||||||
|
<div class="text-muted small mt-1">stale items</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 2: Charts (simple bar-like display) -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<!-- Content by State -->
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-transparent border-0">
|
||||||
|
<h5 class="card-title mb-0">
|
||||||
|
<i class="fa fa-bar-chart me-1"/>Content by Status
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<t t-foreach="Object.entries(state.data.contentByState)" t-as="entry" t-key="entry[0]">
|
||||||
|
<div class="d-flex align-items-center mb-2" style="cursor:pointer"
|
||||||
|
t-on-click="() => this.onClickState(entry[0])">
|
||||||
|
<span class="text-truncate me-2" style="min-width:100px;" t-out="getStateLabel(entry[0])"/>
|
||||||
|
<div class="progress flex-grow-1 me-2" style="height: 20px;">
|
||||||
|
<div class="progress-bar"
|
||||||
|
t-attf-style="width: #{state.data.totalContent ? (entry[1] / state.data.totalContent * 100) : 0}%; background-color: #{getStateColor(entry[0])};"
|
||||||
|
t-out="entry[1]"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<div t-if="!Object.keys(state.data.contentByState).length"
|
||||||
|
class="text-muted text-center py-3">
|
||||||
|
No content yet
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Content by Type -->
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-transparent border-0">
|
||||||
|
<h5 class="card-title mb-0">
|
||||||
|
<i class="fa fa-pie-chart me-1"/>Content by Type
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<t t-foreach="Object.entries(state.data.contentByType)" t-as="entry" t-key="entry[0]">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="badge bg-secondary" t-out="getTypeLabel(entry[0])"/>
|
||||||
|
<strong t-out="entry[1]"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<div t-if="!Object.keys(state.data.contentByType).length"
|
||||||
|
class="text-muted text-center py-3">
|
||||||
|
No content yet
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 3: Recent Content -->
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-transparent border-0">
|
||||||
|
<h5 class="card-title mb-0">
|
||||||
|
<i class="fa fa-clock-o me-1"/>Recent Content
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>SEO Score</th>
|
||||||
|
<th>Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<t t-foreach="state.data.recentContent" t-as="item" t-key="item.id">
|
||||||
|
<tr style="cursor:pointer" t-on-click="() => this.onClickRecent(item.id)">
|
||||||
|
<td class="text-truncate" style="max-width:250px;" t-out="item.name"/>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-light text-dark" t-out="getTypeLabel(item.content_type)"/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge"
|
||||||
|
t-attf-style="background-color: #{getStateColor(item.state)}; color: white;"
|
||||||
|
t-out="getStateLabel(item.state)"/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="progress flex-grow-1 me-1" style="height:8px;width:60px;">
|
||||||
|
<div class="progress-bar"
|
||||||
|
t-attf-class="bg-#{getScoreColor(item.seo_score)}"
|
||||||
|
t-attf-style="width: #{item.seo_score}%"/>
|
||||||
|
</div>
|
||||||
|
<small t-out="item.seo_score"/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-muted small" t-out="item.create_date"/>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
<tr t-if="!state.data.recentContent.length">
|
||||||
|
<td colspan="5" class="text-center text-muted py-3">
|
||||||
|
No content created yet
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Error / No Data -->
|
||||||
|
<div t-if="!state.loading and !state.data" class="text-center py-5">
|
||||||
|
<i class="fa fa-exclamation-triangle fa-3x text-warning"/>
|
||||||
|
<p class="mt-3 text-muted">Failed to load dashboard data. Please try again.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- Brand Voice List View -->
|
||||||
|
<record id="otk_seo_brand_voice_view_tree" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.brand.voice.list</field>
|
||||||
|
<field name="model">otk.seo.brand.voice</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Brand Voices">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="voice_tone"/>
|
||||||
|
<field name="writing_style"/>
|
||||||
|
<field name="is_default" widget="boolean_toggle"/>
|
||||||
|
<field name="content_count"/>
|
||||||
|
<field name="active" column_invisible="1"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Brand Voice Form View -->
|
||||||
|
<record id="otk_seo_brand_voice_view_form" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.brand.voice.form</field>
|
||||||
|
<field name="model">otk.seo.brand.voice</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Brand Voice">
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_button_box" name="button_box">
|
||||||
|
<button name="action_view_content" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-file-text-o">
|
||||||
|
<field name="content_count" widget="statinfo" string="Content"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<widget name="web_ribbon" title="Default"
|
||||||
|
bg_color="text-bg-success" invisible="not is_default"/>
|
||||||
|
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1>
|
||||||
|
<field name="name" placeholder="Brand Name"/>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<group string="Settings">
|
||||||
|
<field name="is_default" widget="boolean_toggle"/>
|
||||||
|
<field name="sequence"/>
|
||||||
|
</group>
|
||||||
|
<group string="Voice Style">
|
||||||
|
<field name="voice_tone"/>
|
||||||
|
<field name="writing_style"/>
|
||||||
|
<field name="voice_personality"
|
||||||
|
placeholder="e.g., innovative, trustworthy, approachable"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<notebook>
|
||||||
|
<page string="Brand Identity" name="identity">
|
||||||
|
<group>
|
||||||
|
<group string="Brand Description">
|
||||||
|
<field name="brand_description" nolabel="1" colspan="2"
|
||||||
|
placeholder="Describe your brand, its values, and mission..."/>
|
||||||
|
</group>
|
||||||
|
<group string="Target Audience">
|
||||||
|
<field name="target_audience" nolabel="1" colspan="2"
|
||||||
|
placeholder="Describe your ideal reader/customer..."/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page string="Vocabulary" name="vocabulary">
|
||||||
|
<group>
|
||||||
|
<group string="Preferred Words">
|
||||||
|
<field name="preferred_words" nolabel="1" colspan="2"
|
||||||
|
placeholder="Words to use (one per line)..."/>
|
||||||
|
</group>
|
||||||
|
<group string="Words to Avoid">
|
||||||
|
<field name="avoided_words" nolabel="1" colspan="2"
|
||||||
|
placeholder="Words to avoid (one per line)..."/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Industry Terminology">
|
||||||
|
<field name="industry_terms" nolabel="1" colspan="2"
|
||||||
|
placeholder="Industry terms and preferred usage..."/>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page string="Custom Instructions" name="instructions">
|
||||||
|
<group string="Additional AI Instructions">
|
||||||
|
<field name="custom_instructions" nolabel="1" colspan="2"
|
||||||
|
placeholder="Additional rules for the AI to follow when generating content..."/>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page string="Examples" name="examples">
|
||||||
|
<group>
|
||||||
|
<group string="Good Example">
|
||||||
|
<field name="example_good" nolabel="1" colspan="2"
|
||||||
|
placeholder="Paste content that represents your brand voice well..."/>
|
||||||
|
</group>
|
||||||
|
<group string="What to Avoid">
|
||||||
|
<field name="example_bad" nolabel="1" colspan="2"
|
||||||
|
placeholder="Paste content that does NOT match your brand voice..."/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Brand Voice Search View -->
|
||||||
|
<record id="otk_seo_brand_voice_view_search" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.brand.voice.search</field>
|
||||||
|
<field name="model">otk.seo.brand.voice</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Brand Voices">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="voice_tone"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="filter_default" string="Default Voice"
|
||||||
|
domain="[('is_default', '=', True)]"/>
|
||||||
|
<filter name="filter_active" string="Active"
|
||||||
|
domain="[('active', '=', True)]"/>
|
||||||
|
<filter name="filter_archived" string="Archived"
|
||||||
|
domain="[('active', '=', False)]"/>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Action -->
|
||||||
|
<record id="action_otk_seo_brand_voice" model="ir.actions.act_window">
|
||||||
|
<field name="name">Brand Voices</field>
|
||||||
|
<field name="res_model">otk.seo.brand.voice</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="search_view_id" ref="otk_seo_brand_voice_view_search"/>
|
||||||
|
<field name="context">{}</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Create your first brand voice!
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Define your brand's tone, style, and vocabulary to ensure
|
||||||
|
all AI-generated content follows your brand guidelines.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<menuitem id="menu_seo_content_root"
|
||||||
|
name="SEO Content"
|
||||||
|
groups="otoolkit_seo_content.group_seo_content_user"
|
||||||
|
web_icon="otoolkit_seo_content,static/description/icon.png"
|
||||||
|
sequence="90"/>
|
||||||
|
|
||||||
|
<!-- Content Management Submenu -->
|
||||||
|
<menuitem id="menu_seo_content_generator"
|
||||||
|
name="Content"
|
||||||
|
parent="menu_seo_content_root"
|
||||||
|
sequence="5"/>
|
||||||
|
|
||||||
|
<!-- Quick Action: Generate Content (prominent at top) -->
|
||||||
|
<menuitem id="menu_generate_content"
|
||||||
|
name="Generate Content"
|
||||||
|
parent="menu_seo_content_root"
|
||||||
|
action="action_generate_content_wizard"
|
||||||
|
sequence="10"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_my_content"
|
||||||
|
name="All Content"
|
||||||
|
parent="menu_seo_content_generator"
|
||||||
|
action="action_otk_seo_content"
|
||||||
|
sequence="10"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_bulk_generation"
|
||||||
|
name="Bulk Jobs"
|
||||||
|
parent="menu_seo_content_generator"
|
||||||
|
action="action_otk_seo_content_batch"
|
||||||
|
sequence="20"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_ideas_queue"
|
||||||
|
name="Ideas Queue"
|
||||||
|
parent="menu_seo_content_generator"
|
||||||
|
action="action_otk_seo_content_idea"
|
||||||
|
sequence="25"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_generated_images"
|
||||||
|
name="Images"
|
||||||
|
parent="menu_seo_content_generator"
|
||||||
|
action="action_otk_seo_content_image"
|
||||||
|
sequence="30"/>
|
||||||
|
|
||||||
|
<!-- Reporting Submenu -->
|
||||||
|
<menuitem id="menu_seo_content_reporting"
|
||||||
|
name="Reporting"
|
||||||
|
parent="menu_seo_content_root"
|
||||||
|
sequence="80"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_seo_dashboard"
|
||||||
|
name="Dashboard"
|
||||||
|
parent="menu_seo_content_reporting"
|
||||||
|
action="action_seo_content_dashboard_owl"
|
||||||
|
sequence="5"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_token_usage"
|
||||||
|
name="Token Usage"
|
||||||
|
parent="menu_seo_content_reporting"
|
||||||
|
action="action_seo_content_token_usage"
|
||||||
|
sequence="10"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_content_by_type"
|
||||||
|
name="Content by Type"
|
||||||
|
parent="menu_seo_content_reporting"
|
||||||
|
action="action_seo_content_by_type"
|
||||||
|
sequence="20"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_content_by_state"
|
||||||
|
name="Content by Status"
|
||||||
|
parent="menu_seo_content_reporting"
|
||||||
|
action="action_seo_content_by_state"
|
||||||
|
sequence="30"/>
|
||||||
|
|
||||||
|
<!-- Configuration Submenu -->
|
||||||
|
<menuitem id="menu_seo_content_config"
|
||||||
|
name="Configuration"
|
||||||
|
parent="menu_seo_content_root"
|
||||||
|
sequence="90"
|
||||||
|
groups="base.group_system"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_content_templates"
|
||||||
|
name="Content Templates"
|
||||||
|
parent="menu_seo_content_config"
|
||||||
|
action="action_otk_seo_template"
|
||||||
|
sequence="10"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_brand_voices"
|
||||||
|
name="Brand Voices"
|
||||||
|
parent="menu_seo_content_config"
|
||||||
|
action="action_otk_seo_brand_voice"
|
||||||
|
sequence="20"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_reference_library"
|
||||||
|
name="Reference Library"
|
||||||
|
parent="menu_seo_content_config"
|
||||||
|
action="action_otk_seo_reference"
|
||||||
|
sequence="30"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_reference_tags"
|
||||||
|
name="Reference Tags"
|
||||||
|
parent="menu_seo_content_config"
|
||||||
|
action="action_otk_seo_reference_tag"
|
||||||
|
sequence="35"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="res_config_settings_view_form_seo_content" model="ir.ui.view">
|
||||||
|
<field name="name">res.config.settings.view.form.inherit.seo.content</field>
|
||||||
|
<field name="model">res.config.settings</field>
|
||||||
|
<field name="inherit_id" ref="otoolkit_auth.res_config_settings_view_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//app[@name='otoolkit_auth']" position="after">
|
||||||
|
<app string="SEO Content" name="otoolkit_seo_content">
|
||||||
|
<block title="SEO Content Settings">
|
||||||
|
<setting string="Default Blog" help="Default blog for publishing generated content">
|
||||||
|
<field name="seo_default_blog_id"/>
|
||||||
|
</setting>
|
||||||
|
<setting string="Default Image Count" help="Number of images to generate by default">
|
||||||
|
<field name="seo_default_image_count"/>
|
||||||
|
</setting>
|
||||||
|
<setting string="Default Image Quality">
|
||||||
|
<field name="seo_default_image_quality" widget="radio"/>
|
||||||
|
</setting>
|
||||||
|
<setting string="Auto-generate Alt Text" help="Automatically create SEO-friendly alt text for images">
|
||||||
|
<field name="seo_auto_generate_alt_text"/>
|
||||||
|
</setting>
|
||||||
|
<setting string="Version History Limit" help="Maximum number of content versions to keep">
|
||||||
|
<field name="seo_max_versions"/>
|
||||||
|
</setting>
|
||||||
|
</block>
|
||||||
|
</app>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- Batch List View -->
|
||||||
|
<record id="otk_seo_content_batch_view_list" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.batch.list</field>
|
||||||
|
<field name="model">otk.seo.content.batch</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Batch Jobs" decoration-info="state == 'draft'"
|
||||||
|
decoration-warning="state == 'processing'"
|
||||||
|
decoration-success="state == 'done'"
|
||||||
|
decoration-danger="state == 'failed'">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="source_type"/>
|
||||||
|
<field name="content_type"/>
|
||||||
|
<field name="total_items"/>
|
||||||
|
<field name="completed_items"/>
|
||||||
|
<field name="failed_items"/>
|
||||||
|
<field name="progress_percent" widget="progressbar"/>
|
||||||
|
<field name="state" widget="badge"
|
||||||
|
decoration-info="state == 'draft'"
|
||||||
|
decoration-warning="state in ('queued', 'processing')"
|
||||||
|
decoration-success="state == 'done'"
|
||||||
|
decoration-danger="state == 'failed'"/>
|
||||||
|
<field name="create_date" optional="hide"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Batch Form View -->
|
||||||
|
<record id="otk_seo_content_batch_view_form" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.batch.form</field>
|
||||||
|
<field name="model">otk.seo.content.batch</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Batch Content Generation">
|
||||||
|
<header>
|
||||||
|
<button name="action_prepare_items" string="Add Items from Source"
|
||||||
|
type="object" class="btn-secondary" icon="fa-plus"
|
||||||
|
invisible="state != 'draft' or not source_type"
|
||||||
|
help="Add items from the selected source (products, CSV, or keywords). Existing items are preserved."/>
|
||||||
|
<button name="action_clear_items" string="Clear All Items"
|
||||||
|
type="object" class="btn-secondary" icon="fa-trash"
|
||||||
|
invisible="state != 'draft' or total_items == 0"
|
||||||
|
confirm="This will remove all items from the batch. Continue?"
|
||||||
|
help="Remove all items from the batch to start fresh."/>
|
||||||
|
<button name="action_start_batch" string="Start Generation"
|
||||||
|
type="object" class="btn-primary"
|
||||||
|
invisible="state != 'draft' or total_items == 0"
|
||||||
|
confirm="This will start generating content for all items. Continue?"
|
||||||
|
help="Start generating content for all prepared items. Items will be processed one by one."/>
|
||||||
|
<button name="action_pause" string="Pause"
|
||||||
|
type="object" class="btn-secondary" icon="fa-pause"
|
||||||
|
invisible="state not in ('queued', 'processing')"
|
||||||
|
help="Pause batch processing. Items currently in progress will finish."/>
|
||||||
|
<button name="action_resume" string="Resume"
|
||||||
|
type="object" class="btn-primary" icon="fa-play"
|
||||||
|
invisible="state != 'paused'"
|
||||||
|
help="Resume paused batch processing."/>
|
||||||
|
<button name="action_cancel" string="Cancel"
|
||||||
|
type="object" class="btn-secondary"
|
||||||
|
invisible="state not in ('queued', 'processing', 'paused')"
|
||||||
|
help="Stop the batch processing. Items already completed will be kept."/>
|
||||||
|
<button name="action_retry_failed" string="Retry Failed"
|
||||||
|
type="object" class="btn-warning"
|
||||||
|
invisible="failed_items == 0 or state not in ('done', 'failed')"
|
||||||
|
help="Retry generating content for all failed items"/>
|
||||||
|
<field name="state" widget="statusbar"
|
||||||
|
statusbar_visible="draft,queued,processing,done"
|
||||||
|
statusbar_colors='{"paused": "warning"}'/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_button_box" name="button_box">
|
||||||
|
<button name="action_view_content" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-file-text-o"
|
||||||
|
invisible="content_count == 0">
|
||||||
|
<field name="content_count" widget="statinfo" string="Content"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1>
|
||||||
|
<field name="name" placeholder="Batch Name"/>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Paused Alert -->
|
||||||
|
<div class="alert alert-warning" role="alert"
|
||||||
|
invisible="state != 'paused'">
|
||||||
|
<strong><i class="fa fa-pause-circle"/> Paused:</strong>
|
||||||
|
Batch processing is paused. Click "Resume" to continue.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Bar with ETA -->
|
||||||
|
<div class="alert alert-info" role="alert"
|
||||||
|
invisible="state not in ('queued', 'processing')">
|
||||||
|
<strong>Processing:</strong>
|
||||||
|
<field name="completed_items" class="oe_inline"/> /
|
||||||
|
<field name="total_items" class="oe_inline"/> items completed
|
||||||
|
<span invisible="eta_minutes == 0" class="ms-2">
|
||||||
|
— ETA: <field name="eta_minutes" class="oe_inline"/> min
|
||||||
|
</span>
|
||||||
|
<field name="progress_percent" widget="progressbar" class="mt-2"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Summary -->
|
||||||
|
<div class="alert alert-warning" role="alert" invisible="failed_items == 0">
|
||||||
|
<strong>Warning:</strong>
|
||||||
|
<field name="failed_items" class="oe_inline"/> items failed.
|
||||||
|
Check the items below for error details.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<group string="Source">
|
||||||
|
<field name="concurrency" readonly="state != 'draft'"
|
||||||
|
help="Items to process per cron run (1-10)"/>
|
||||||
|
<field name="source_type" widget="radio"
|
||||||
|
readonly="state != 'draft'"/>
|
||||||
|
<field name="product_ids" widget="many2many_tags"
|
||||||
|
invisible="source_type != 'products'"
|
||||||
|
readonly="state != 'draft'"/>
|
||||||
|
<field name="csv_file" filename="csv_filename"
|
||||||
|
invisible="source_type != 'csv'"
|
||||||
|
readonly="state != 'draft'"/>
|
||||||
|
<field name="csv_filename" invisible="1"/>
|
||||||
|
<field name="keywords_list"
|
||||||
|
invisible="source_type != 'keywords'"
|
||||||
|
readonly="state != 'draft'"
|
||||||
|
placeholder="keyword1, topic1 keyword2, topic2 ..."/>
|
||||||
|
</group>
|
||||||
|
<group string="Content Settings">
|
||||||
|
<field name="content_type" readonly="state != 'draft'"/>
|
||||||
|
<field name="template_id" readonly="state != 'draft'"
|
||||||
|
domain="[('content_type', '=', content_type)]"/>
|
||||||
|
<field name="brand_voice_id" readonly="state != 'draft'"/>
|
||||||
|
<field name="tone" readonly="state != 'draft'"/>
|
||||||
|
<field name="target_word_count" readonly="state != 'draft'"/>
|
||||||
|
<field name="language_id" readonly="state != 'draft'"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<group string="Image Settings">
|
||||||
|
<field name="include_images" widget="boolean_toggle"
|
||||||
|
readonly="state != 'draft'"/>
|
||||||
|
<field name="image_count" invisible="not include_images"
|
||||||
|
readonly="state != 'draft'"/>
|
||||||
|
<field name="image_style" invisible="not include_images"
|
||||||
|
readonly="state != 'draft'"/>
|
||||||
|
</group>
|
||||||
|
<group string="Blog Settings" invisible="content_type != 'blog_post'">
|
||||||
|
<field name="blog_id" readonly="state != 'draft'"
|
||||||
|
placeholder="Select target blog"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group invisible="state == 'draft'">
|
||||||
|
<group string="Timing">
|
||||||
|
<field name="started_at"/>
|
||||||
|
<field name="completed_at"/>
|
||||||
|
<field name="duration" widget="float_time"/>
|
||||||
|
</group>
|
||||||
|
<group/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<notebook invisible="not item_ids">
|
||||||
|
<page string="Items" name="items">
|
||||||
|
<field name="item_ids" readonly="state != 'draft'">
|
||||||
|
<list decoration-success="state == 'done'"
|
||||||
|
decoration-danger="state == 'failed'"
|
||||||
|
decoration-warning="state == 'processing'"
|
||||||
|
decoration-muted="state == 'cancelled'"
|
||||||
|
editable="bottom">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="keywords"/>
|
||||||
|
<field name="topic" optional="hide"/>
|
||||||
|
<field name="product_id" optional="show"/>
|
||||||
|
<field name="state" widget="badge"
|
||||||
|
decoration-info="state in ('draft', 'pending')"
|
||||||
|
decoration-warning="state == 'processing'"
|
||||||
|
decoration-success="state == 'done'"
|
||||||
|
decoration-danger="state == 'failed'"
|
||||||
|
readonly="1"/>
|
||||||
|
<field name="content_id" optional="show" readonly="1"/>
|
||||||
|
<field name="error_message" optional="hide" readonly="1"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
<chatter/>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Batch Search View -->
|
||||||
|
<record id="otk_seo_content_batch_view_search" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.batch.search</field>
|
||||||
|
<field name="model">otk.seo.content.batch</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Batches">
|
||||||
|
<field name="name"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="filter_draft" string="Draft"
|
||||||
|
domain="[('state', '=', 'draft')]"/>
|
||||||
|
<filter name="filter_processing" string="Processing"
|
||||||
|
domain="[('state', 'in', ('queued', 'processing'))]"/>
|
||||||
|
<filter name="filter_done" string="Completed"
|
||||||
|
domain="[('state', '=', 'done')]"/>
|
||||||
|
<filter name="filter_failed" string="Failed"
|
||||||
|
domain="[('state', '=', 'failed')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="filter_my_batches" string="My Batches"
|
||||||
|
domain="[('create_uid', '=', uid)]"/>
|
||||||
|
<group>
|
||||||
|
<filter name="group_state" string="Status"
|
||||||
|
context="{'group_by': 'state'}"/>
|
||||||
|
<filter name="group_source" string="Source Type"
|
||||||
|
context="{'group_by': 'source_type'}"/>
|
||||||
|
<filter name="group_date" string="Created Date"
|
||||||
|
context="{'group_by': 'create_date:month'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Batch Action -->
|
||||||
|
<record id="action_otk_seo_content_batch" model="ir.actions.act_window">
|
||||||
|
<field name="name">Bulk Generation</field>
|
||||||
|
<field name="res_model">otk.seo.content.batch</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="search_view_id" ref="otk_seo_content_batch_view_search"/>
|
||||||
|
<field name="context">{'search_default_filter_my_batches': 1}</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Create your first bulk generation job!
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Generate SEO content for multiple products or topics at once.
|
||||||
|
Select products, upload a CSV, or enter keywords to get started.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- SEO Content Idea List View -->
|
||||||
|
<record id="otk_seo_content_idea_view_tree" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.idea.list</field>
|
||||||
|
<field name="model">otk.seo.content.idea</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Content Ideas"
|
||||||
|
decoration-info="state == 'idea'"
|
||||||
|
decoration-warning="state == 'scheduled'"
|
||||||
|
decoration-success="state == 'done'"
|
||||||
|
decoration-danger="state == 'failed'"
|
||||||
|
decoration-muted="state == 'cancelled'">
|
||||||
|
<field name="priority" widget="priority"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="content_type"/>
|
||||||
|
<field name="keywords" optional="show"/>
|
||||||
|
<field name="is_recurring" string="Rec." widget="boolean" optional="show"/>
|
||||||
|
<field name="scheduled_date"/>
|
||||||
|
<field name="state" widget="badge"
|
||||||
|
decoration-info="state == 'idea'"
|
||||||
|
decoration-warning="state in ('scheduled', 'generating')"
|
||||||
|
decoration-success="state == 'done'"
|
||||||
|
decoration-danger="state == 'failed'"
|
||||||
|
decoration-muted="state == 'cancelled'"/>
|
||||||
|
<field name="content_id" optional="show"/>
|
||||||
|
<button name="action_generate_now" type="object"
|
||||||
|
string="Generate Now" icon="fa-bolt"
|
||||||
|
invisible="state not in ('idea', 'scheduled')"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- SEO Content Idea Form View -->
|
||||||
|
<record id="otk_seo_content_idea_view_form" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.idea.form</field>
|
||||||
|
<field name="model">otk.seo.content.idea</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Content Idea">
|
||||||
|
<header>
|
||||||
|
<button name="action_schedule_now" type="object"
|
||||||
|
string="Schedule Now" class="oe_highlight"
|
||||||
|
invisible="state != 'idea'"/>
|
||||||
|
<button name="action_generate_now" type="object"
|
||||||
|
string="Generate Now" class="oe_highlight"
|
||||||
|
invisible="state not in ('idea', 'scheduled')"/>
|
||||||
|
<button name="action_cancel" type="object"
|
||||||
|
string="Cancel"
|
||||||
|
invisible="state not in ('idea', 'scheduled')"/>
|
||||||
|
<button name="action_reset_to_idea" type="object"
|
||||||
|
string="Reset to Idea"
|
||||||
|
invisible="state not in ('failed', 'cancelled')"/>
|
||||||
|
<field name="state" widget="statusbar"
|
||||||
|
statusbar_visible="idea,scheduled,generating,done"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_button_box" name="button_box">
|
||||||
|
<button name="action_view_content" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-file-text-o"
|
||||||
|
invisible="not content_id">
|
||||||
|
<span class="o_stat_text">View Content</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<widget name="web_ribbon" title="Failed"
|
||||||
|
bg_color="text-bg-danger" invisible="state != 'failed'"/>
|
||||||
|
<widget name="web_ribbon" title="Cancelled"
|
||||||
|
bg_color="text-bg-secondary" invisible="state != 'cancelled'"/>
|
||||||
|
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1>
|
||||||
|
<field name="name" placeholder="Content idea title or topic..."
|
||||||
|
readonly="state not in ('idea', 'scheduled')"/>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<group string="Idea Details">
|
||||||
|
<field name="priority" widget="priority"/>
|
||||||
|
<field name="keywords" placeholder="target, keywords, here"
|
||||||
|
readonly="state not in ('idea', 'scheduled')"/>
|
||||||
|
<field name="topic_brief" placeholder="Describe what the content should cover..."
|
||||||
|
readonly="state not in ('idea', 'scheduled')"/>
|
||||||
|
<field name="product_id"
|
||||||
|
readonly="state not in ('idea', 'scheduled')"/>
|
||||||
|
<field name="reference_ids" widget="many2many_tags"
|
||||||
|
readonly="state not in ('idea', 'scheduled')"/>
|
||||||
|
</group>
|
||||||
|
<group string="Schedule">
|
||||||
|
<field name="scheduled_date"
|
||||||
|
readonly="state not in ('idea', 'scheduled')"/>
|
||||||
|
<field name="generated_at" readonly="1"
|
||||||
|
invisible="state != 'done'"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<notebook>
|
||||||
|
<page string="Generation Settings" name="settings">
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="content_type"
|
||||||
|
readonly="state not in ('idea', 'scheduled')"/>
|
||||||
|
<field name="template_id"
|
||||||
|
domain="[('content_type', '=', content_type)]"
|
||||||
|
readonly="state not in ('idea', 'scheduled')"/>
|
||||||
|
<field name="brand_voice_id"
|
||||||
|
readonly="state not in ('idea', 'scheduled')"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="tone"
|
||||||
|
readonly="state not in ('idea', 'scheduled')"/>
|
||||||
|
<field name="target_word_count"
|
||||||
|
readonly="state not in ('idea', 'scheduled')"/>
|
||||||
|
<field name="language_id"
|
||||||
|
readonly="state not in ('idea', 'scheduled')"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page string="Images" name="images">
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="include_images"
|
||||||
|
readonly="state not in ('idea', 'scheduled')"/>
|
||||||
|
<field name="image_count"
|
||||||
|
invisible="not include_images"
|
||||||
|
readonly="state not in ('idea', 'scheduled')"/>
|
||||||
|
<field name="image_style"
|
||||||
|
invisible="not include_images"
|
||||||
|
readonly="state not in ('idea', 'scheduled')"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page string="Recurring" name="recurring">
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="is_recurring"
|
||||||
|
readonly="state not in ('idea', 'scheduled')"/>
|
||||||
|
<field name="recurrence_type"
|
||||||
|
invisible="not is_recurring"
|
||||||
|
readonly="state not in ('idea', 'scheduled')"/>
|
||||||
|
<field name="recurrence_end_date"
|
||||||
|
invisible="not is_recurring"
|
||||||
|
readonly="state not in ('idea', 'scheduled')"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="parent_idea_id" readonly="1"
|
||||||
|
invisible="not parent_idea_id"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<div class="alert alert-info" role="alert"
|
||||||
|
invisible="not is_recurring">
|
||||||
|
<i class="fa fa-repeat" title="Recurring"/>
|
||||||
|
<strong> Recurring Idea:</strong>
|
||||||
|
A new idea will automatically be created and scheduled
|
||||||
|
after this one is processed.
|
||||||
|
</div>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page string="Auto-Publish" name="publish">
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="auto_create_blog_post"
|
||||||
|
readonly="state not in ('idea', 'scheduled')"/>
|
||||||
|
<field name="blog_id"
|
||||||
|
invisible="not auto_create_blog_post"
|
||||||
|
readonly="state not in ('idea', 'scheduled')"/>
|
||||||
|
<field name="auto_publish"
|
||||||
|
invisible="not auto_create_blog_post"
|
||||||
|
readonly="state not in ('idea', 'scheduled')"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<div class="alert alert-warning" role="alert"
|
||||||
|
invisible="not auto_publish">
|
||||||
|
<i class="fa fa-exclamation-triangle" title="Auto-Publish"/>
|
||||||
|
<strong>Auto-Publish Enabled:</strong>
|
||||||
|
Content will be automatically published to your website when generated.
|
||||||
|
Make sure you review scheduled ideas before their scheduled time.
|
||||||
|
</div>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page string="Result" name="result" invisible="state not in ('done', 'failed')">
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="content_id" readonly="1"/>
|
||||||
|
<field name="blog_post_id" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="generated_at" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Error Details" invisible="state != 'failed'">
|
||||||
|
<field name="error_message" readonly="1" nolabel="1"/>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
<chatter/>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- SEO Content Idea Search View -->
|
||||||
|
<record id="otk_seo_content_idea_view_search" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.idea.search</field>
|
||||||
|
<field name="model">otk.seo.content.idea</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Ideas">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="keywords"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="filter_ideas" string="Ideas"
|
||||||
|
domain="[('state', '=', 'idea')]"/>
|
||||||
|
<filter name="filter_scheduled" string="Scheduled"
|
||||||
|
domain="[('state', '=', 'scheduled')]"/>
|
||||||
|
<filter name="filter_done" string="Completed"
|
||||||
|
domain="[('state', '=', 'done')]"/>
|
||||||
|
<filter name="filter_failed" string="Failed"
|
||||||
|
domain="[('state', '=', 'failed')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="filter_high_priority" string="High Priority"
|
||||||
|
domain="[('priority', 'in', ('2', '3'))]"/>
|
||||||
|
<filter name="filter_auto_publish" string="Auto-Publish"
|
||||||
|
domain="[('auto_publish', '=', True)]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="filter_my_ideas" string="My Ideas"
|
||||||
|
domain="[('create_uid', '=', uid)]"/>
|
||||||
|
<group>
|
||||||
|
<filter name="group_state" string="Status"
|
||||||
|
context="{'group_by': 'state'}"/>
|
||||||
|
<filter name="group_type" string="Content Type"
|
||||||
|
context="{'group_by': 'content_type'}"/>
|
||||||
|
<filter name="group_priority" string="Priority"
|
||||||
|
context="{'group_by': 'priority'}"/>
|
||||||
|
<filter name="group_scheduled" string="Scheduled Date"
|
||||||
|
context="{'group_by': 'scheduled_date:day'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- SEO Content Idea Kanban View -->
|
||||||
|
<record id="otk_seo_content_idea_view_kanban" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.idea.kanban</field>
|
||||||
|
<field name="model">otk.seo.content.idea</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<kanban default_group_by="state" class="o_kanban_small_column">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="state"/>
|
||||||
|
<field name="priority"/>
|
||||||
|
<field name="scheduled_date"/>
|
||||||
|
<field name="content_type"/>
|
||||||
|
<field name="auto_publish"/>
|
||||||
|
<progressbar field="state"
|
||||||
|
colors='{"idea": "info", "scheduled": "warning", "generating": "warning", "done": "success", "failed": "danger", "cancelled": "muted"}'/>
|
||||||
|
<templates>
|
||||||
|
<t t-name="card">
|
||||||
|
<div class="oe_kanban_card oe_kanban_global_click">
|
||||||
|
<div class="oe_kanban_content">
|
||||||
|
<div class="o_kanban_record_title d-flex align-items-center">
|
||||||
|
<field name="priority" widget="priority" class="me-2"/>
|
||||||
|
<strong class="text-truncate"><field name="name"/></strong>
|
||||||
|
</div>
|
||||||
|
<div class="o_kanban_record_body">
|
||||||
|
<field name="content_type"/>
|
||||||
|
<t t-if="record.scheduled_date.raw_value">
|
||||||
|
<br/>
|
||||||
|
<span class="text-muted">
|
||||||
|
<i class="fa fa-clock-o" title="Scheduled Date"/> <field name="scheduled_date"/>
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<div class="o_kanban_record_bottom mt-2">
|
||||||
|
<div class="oe_kanban_bottom_left">
|
||||||
|
<t t-if="record.auto_publish.raw_value">
|
||||||
|
<span class="badge text-bg-warning" title="Auto-Publish">
|
||||||
|
<i class="fa fa-globe"/>
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<div class="oe_kanban_bottom_right">
|
||||||
|
<field name="state" widget="label_selection"
|
||||||
|
options="{'classes': {'idea': 'info', 'scheduled': 'warning', 'generating': 'warning', 'done': 'success', 'failed': 'danger', 'cancelled': 'secondary'}}"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
</kanban>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- SEO Content Idea Calendar View -->
|
||||||
|
<record id="otk_seo_content_idea_view_calendar" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.idea.calendar</field>
|
||||||
|
<field name="model">otk.seo.content.idea</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<calendar string="Scheduled Ideas" date_start="scheduled_date"
|
||||||
|
color="priority" mode="month" quick_create="0"
|
||||||
|
event_open_popup="true">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="content_type"/>
|
||||||
|
<field name="keywords"/>
|
||||||
|
<field name="state"/>
|
||||||
|
<field name="auto_publish"/>
|
||||||
|
<field name="is_recurring"/>
|
||||||
|
</calendar>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Action -->
|
||||||
|
<record id="action_otk_seo_content_idea" model="ir.actions.act_window">
|
||||||
|
<field name="name">Ideas Queue</field>
|
||||||
|
<field name="res_model">otk.seo.content.idea</field>
|
||||||
|
<field name="view_mode">kanban,list,form,calendar</field>
|
||||||
|
<field name="search_view_id" ref="otk_seo_content_idea_view_search"/>
|
||||||
|
<field name="context">{'search_default_filter_ideas': 1, 'search_default_filter_scheduled': 1}</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Add your first content idea!
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Create content ideas and schedule them for automatic generation.
|
||||||
|
Ideas can include keywords, topic briefs, and publishing settings.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- SEO Content Image List View -->
|
||||||
|
<record id="otk_seo_content_image_view_tree" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.image.list</field>
|
||||||
|
<field name="model">otk.seo.content.image</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Generated Images"
|
||||||
|
decoration-info="state == 'draft'"
|
||||||
|
decoration-warning="state in ('pending', 'processing')"
|
||||||
|
decoration-success="state == 'done'"
|
||||||
|
decoration-danger="state == 'error'">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="image" widget="image" options="{'size': [64, 64]}"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="content_id"/>
|
||||||
|
<field name="style"/>
|
||||||
|
<field name="aspect_ratio"/>
|
||||||
|
<field name="state" widget="badge"/>
|
||||||
|
<field name="is_cover"/>
|
||||||
|
<field name="is_optimized" widget="boolean"/>
|
||||||
|
<field name="size_reduction" optional="hide"/>
|
||||||
|
<field name="alt_text"/>
|
||||||
|
<field name="token_cost" optional="hide"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- SEO Content Image Form View -->
|
||||||
|
<record id="otk_seo_content_image_view_form" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.image.form</field>
|
||||||
|
<field name="model">otk.seo.content.image</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Generated Image">
|
||||||
|
<header>
|
||||||
|
<button name="action_generate" type="object" string="Generate"
|
||||||
|
class="oe_highlight" invisible="state not in ('draft', 'pending')"/>
|
||||||
|
<button name="action_regenerate" type="object" string="Regenerate"
|
||||||
|
class="oe_highlight" icon="fa-refresh"
|
||||||
|
invisible="state not in ('done', 'error')"
|
||||||
|
confirm="This will generate a new image with the current settings. The previous image will be saved as backup. Continue?"/>
|
||||||
|
<button name="action_poll_status" type="object" string="Check Status"
|
||||||
|
invisible="state != 'processing'"/>
|
||||||
|
<button name="action_retry" type="object" string="Retry"
|
||||||
|
invisible="state != 'error'"/>
|
||||||
|
<button name="action_optimize" type="object" string="Optimize for Web"
|
||||||
|
class="btn-secondary" invisible="state != 'done' or is_optimized"/>
|
||||||
|
<button name="action_restore_original" type="object" string="Restore Original"
|
||||||
|
class="btn-secondary" invisible="not image_original"/>
|
||||||
|
<field name="state" widget="statusbar"
|
||||||
|
statusbar_visible="draft,pending,processing,done"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<widget name="web_ribbon" title="Error"
|
||||||
|
bg_color="text-bg-danger" invisible="state != 'error'"/>
|
||||||
|
<widget name="web_ribbon" title="Optimized"
|
||||||
|
bg_color="text-bg-success" invisible="not is_optimized"/>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<group string="Image">
|
||||||
|
<field name="image" widget="image"
|
||||||
|
options="{'size': [400, 400]}"
|
||||||
|
invisible="state != 'done'"/>
|
||||||
|
<field name="image_original" widget="image"
|
||||||
|
options="{'size': [200, 200]}" readonly="1"
|
||||||
|
invisible="not image_original"
|
||||||
|
string="Previous Image (Backup)"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="content_id" readonly="state != 'draft'"/>
|
||||||
|
</group>
|
||||||
|
<group string="Generation Settings">
|
||||||
|
<field name="style" readonly="state == 'processing'"/>
|
||||||
|
<field name="aspect_ratio" readonly="state == 'processing'"/>
|
||||||
|
<field name="quality" readonly="state == 'processing'"/>
|
||||||
|
<field name="is_cover"/>
|
||||||
|
<field name="is_og_image"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group string="Generation Prompt">
|
||||||
|
<field name="prompt" placeholder="Describe the image you want to generate..."
|
||||||
|
readonly="state == 'processing'"/>
|
||||||
|
<field name="negative_prompt" placeholder="Things to avoid in the image..."
|
||||||
|
readonly="state == 'processing'"/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<div class="alert alert-info" role="alert" title="Regenerate" invisible="state not in ('done', 'error')">
|
||||||
|
<i class="fa fa-info-circle me-2"/>
|
||||||
|
<strong>Want a different image?</strong>
|
||||||
|
Modify the prompt, style, or settings above, then click <strong>Regenerate</strong>.
|
||||||
|
Your current image will be saved as backup.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<group invisible="state != 'done'">
|
||||||
|
<group string="Result">
|
||||||
|
<field name="revised_prompt" readonly="1"/>
|
||||||
|
<field name="width"/>
|
||||||
|
<field name="height"/>
|
||||||
|
</group>
|
||||||
|
<group string="SEO">
|
||||||
|
<field name="alt_text" placeholder="SEO-friendly alt text"/>
|
||||||
|
<field name="inserted_in_content"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group string="Optimization" invisible="state != 'done'">
|
||||||
|
<group>
|
||||||
|
<field name="is_optimized" widget="boolean_toggle" readonly="1"/>
|
||||||
|
<field name="optimization_quality" invisible="is_optimized"/>
|
||||||
|
<field name="original_size" readonly="1" widget="integer"
|
||||||
|
invisible="not original_size"/>
|
||||||
|
<field name="optimized_size" readonly="1" widget="integer"
|
||||||
|
invisible="not is_optimized"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="size_reduction" readonly="1" widget="progressbar"
|
||||||
|
invisible="not is_optimized"/>
|
||||||
|
<field name="webp_size" readonly="1" widget="integer"
|
||||||
|
invisible="not is_optimized"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group string="Tracking">
|
||||||
|
<group>
|
||||||
|
<field name="task_id" readonly="1"/>
|
||||||
|
<field name="token_cost" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="error_message" readonly="1"
|
||||||
|
invisible="not error_message"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- SEO Content Image Search View -->
|
||||||
|
<record id="otk_seo_content_image_view_search" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.image.search</field>
|
||||||
|
<field name="model">otk.seo.content.image</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Images">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="content_id"/>
|
||||||
|
<field name="prompt"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="filter_done" string="Generated"
|
||||||
|
domain="[('state', '=', 'done')]"/>
|
||||||
|
<filter name="filter_pending" string="Pending"
|
||||||
|
domain="[('state', 'in', ('pending', 'processing'))]"/>
|
||||||
|
<filter name="filter_error" string="Failed"
|
||||||
|
domain="[('state', '=', 'error')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="filter_cover" string="Cover Images"
|
||||||
|
domain="[('is_cover', '=', True)]"/>
|
||||||
|
<filter name="filter_optimized" string="Optimized"
|
||||||
|
domain="[('is_optimized', '=', True)]"/>
|
||||||
|
<filter name="filter_not_optimized" string="Not Optimized"
|
||||||
|
domain="[('is_optimized', '=', False), ('state', '=', 'done')]"/>
|
||||||
|
<group>
|
||||||
|
<filter name="group_state" string="Status"
|
||||||
|
context="{'group_by': 'state'}"/>
|
||||||
|
<filter name="group_style" string="Style"
|
||||||
|
context="{'group_by': 'style'}"/>
|
||||||
|
<filter name="group_content" string="Content"
|
||||||
|
context="{'group_by': 'content_id'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- SEO Content Image Kanban View -->
|
||||||
|
<record id="otk_seo_content_image_view_kanban" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.image.kanban</field>
|
||||||
|
<field name="model">otk.seo.content.image</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<kanban>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="image"/>
|
||||||
|
<field name="state"/>
|
||||||
|
<field name="is_cover"/>
|
||||||
|
<templates>
|
||||||
|
<t t-name="card">
|
||||||
|
<div class="oe_kanban_card oe_kanban_global_click">
|
||||||
|
<div class="o_kanban_image">
|
||||||
|
<field name="image" widget="image"
|
||||||
|
options="{'size': [100, 100]}"/>
|
||||||
|
</div>
|
||||||
|
<div class="oe_kanban_content">
|
||||||
|
<div class="o_kanban_record_title">
|
||||||
|
<field name="name"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<field name="state" widget="badge"/>
|
||||||
|
<span t-if="record.is_cover.raw_value"
|
||||||
|
class="badge text-bg-info ms-1">Cover</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
</kanban>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Action -->
|
||||||
|
<record id="action_otk_seo_content_image" model="ir.actions.act_window">
|
||||||
|
<field name="name">Generated Images</field>
|
||||||
|
<field name="res_model">otk.seo.content.image</field>
|
||||||
|
<field name="view_mode">kanban,list,form</field>
|
||||||
|
<field name="search_view_id" ref="otk_seo_content_image_view_search"/>
|
||||||
|
<field name="context">{}</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
No images generated yet
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Images are automatically generated when you create SEO content
|
||||||
|
with the image generation option enabled.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- SEO Content List View -->
|
||||||
|
<record id="otk_seo_content_view_tree" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.list</field>
|
||||||
|
<field name="model">otk.seo.content</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="SEO Content" default_order="create_date desc" create="False"
|
||||||
|
decoration-info="state == 'draft'"
|
||||||
|
decoration-warning="state == 'images_pending' or is_stale"
|
||||||
|
decoration-success="state in ('approved', 'published') and not is_stale"
|
||||||
|
decoration-muted="state == 'archived'">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="content_type"/>
|
||||||
|
<field name="language_id" optional="show"/>
|
||||||
|
<field name="source_keywords"/>
|
||||||
|
<field name="seo_score" widget="progressbar"
|
||||||
|
options="{'max_value': 100}"/>
|
||||||
|
<field name="word_count" optional="show"/>
|
||||||
|
<field name="image_count" optional="hide"/>
|
||||||
|
<field name="is_stale" string="Stale" widget="boolean"/>
|
||||||
|
<field name="days_since_refresh" optional="hide"/>
|
||||||
|
<field name="state" widget="badge"
|
||||||
|
decoration-info="state == 'draft'"
|
||||||
|
decoration-warning="state in ('generating', 'images_pending')"
|
||||||
|
decoration-success="state in ('review', 'approved', 'published')"
|
||||||
|
decoration-danger="state == 'archived'"/>
|
||||||
|
<field name="create_date" optional="hide"/>
|
||||||
|
<field name="blog_post_id" optional="hide"/>
|
||||||
|
<field name="token_cost" optional="hide"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- SEO Content Form View -->
|
||||||
|
<record id="otk_seo_content_view_form" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.form</field>
|
||||||
|
<field name="model">otk.seo.content</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="SEO Content">
|
||||||
|
<header>
|
||||||
|
<button name="action_generate" type="object" string="Generate"
|
||||||
|
class="oe_highlight" invisible="state != 'draft'"/>
|
||||||
|
<button name="action_regenerate" type="object" string="Regenerate"
|
||||||
|
invisible="state not in ('review', 'approved')"
|
||||||
|
confirm="This will regenerate the content. A version will be saved. Continue?"/>
|
||||||
|
<button name="action_approve" type="object" string="Approve"
|
||||||
|
class="oe_highlight" invisible="state != 'review'"/>
|
||||||
|
<button name="action_create_blog_post" type="object"
|
||||||
|
string="Create Blog Post" class="oe_highlight"
|
||||||
|
invisible="state != 'approved' or blog_post_id"/>
|
||||||
|
<button name="action_apply_to_product" type="object"
|
||||||
|
string="Apply to Product" class="oe_highlight"
|
||||||
|
icon="fa-share"
|
||||||
|
invisible="state not in ('review', 'approved') or content_type != 'product_desc' or not source_product_id"/>
|
||||||
|
<button name="action_back_to_draft" type="object" string="Back to Draft"
|
||||||
|
invisible="state not in ('review', 'approved')"/>
|
||||||
|
<button name="action_insert_images" type="object"
|
||||||
|
string="Insert Images in Content" icon="fa-picture-o"
|
||||||
|
class="btn-secondary"
|
||||||
|
invisible="state not in ('review', 'approved') or not has_insertable_images"/>
|
||||||
|
<button name="action_mark_refreshed" type="object"
|
||||||
|
string="Mark as Refreshed" icon="fa-check"
|
||||||
|
invisible="not is_stale"/>
|
||||||
|
<field name="state" widget="statusbar"
|
||||||
|
statusbar_visible="draft,generating,review,approved,published"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_button_box" name="button_box">
|
||||||
|
<button name="action_view_blog_post" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-newspaper-o"
|
||||||
|
invisible="not blog_post_id">
|
||||||
|
<span class="o_stat_text">Blog Post</span>
|
||||||
|
</button>
|
||||||
|
<button name="action_view_images" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-image">
|
||||||
|
<field name="image_count" widget="statinfo" string="Images"/>
|
||||||
|
</button>
|
||||||
|
<button name="action_restore_version" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-history"
|
||||||
|
invisible="current_version <= 1">
|
||||||
|
<field name="current_version" widget="statinfo" string="Versions"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<widget name="web_ribbon" title="Needs Refresh"
|
||||||
|
bg_color="text-bg-info" invisible="not is_stale"/>
|
||||||
|
<widget name="web_ribbon" title="Images Pending"
|
||||||
|
bg_color="text-bg-warning" invisible="not images_pending or is_stale"/>
|
||||||
|
<widget name="web_ribbon" title="Error"
|
||||||
|
bg_color="text-bg-danger" invisible="not error_message or images_pending or is_stale"/>
|
||||||
|
<widget name="web_ribbon" title="Generating"
|
||||||
|
bg_color="text-bg-warning" invisible="state != 'generating'"/>
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1>
|
||||||
|
<field name="name" placeholder="Content Title"/>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<group string="Content Settings">
|
||||||
|
<field name="content_type"
|
||||||
|
readonly="state != 'draft'"/>
|
||||||
|
<field name="source_keywords" placeholder="keyword1, keyword2, ..."
|
||||||
|
readonly="state != 'draft'"/>
|
||||||
|
<field name="tone"
|
||||||
|
readonly="state != 'draft'"/>
|
||||||
|
<field name="target_word_count"
|
||||||
|
readonly="state != 'draft'"/>
|
||||||
|
<field name="language_id"
|
||||||
|
readonly="state != 'draft'"/>
|
||||||
|
<field name="template_id"
|
||||||
|
domain="[('content_type', '=', content_type)]"
|
||||||
|
readonly="state != 'draft'"/>
|
||||||
|
<field name="brand_voice_id"
|
||||||
|
readonly="state != 'draft'"/>
|
||||||
|
</group>
|
||||||
|
<group string="SEO Analysis">
|
||||||
|
<field name="seo_score" widget="progressbar"
|
||||||
|
options="{'max_value': 100}"/>
|
||||||
|
<field name="keyword_density"/>
|
||||||
|
<field name="word_count"/>
|
||||||
|
<field name="readability_score"/>
|
||||||
|
<field name="is_seo_optimized" widget="boolean_toggle"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<notebook>
|
||||||
|
<page string="Generated Content" name="content">
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="generated_title" placeholder="Generated title will appear here"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="generated_subtitle" placeholder="Generated subtitle"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<field name="generated_content" placeholder="Generated content will appear here..."/>
|
||||||
|
|
||||||
|
<!-- Inline SEO Summary Panel -->
|
||||||
|
<div class="o_seo_inline_panel mt-3 p-3 border rounded bg-light"
|
||||||
|
invisible="not generated_content">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="fa fa-line-chart me-2 text-primary" title="SEO Score"/>
|
||||||
|
<strong class="me-2">SEO Score</strong>
|
||||||
|
<field name="seo_score" widget="progressbar"
|
||||||
|
options="{'max_value': 100}" class="flex-grow-1"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="fa fa-tag me-1"/>
|
||||||
|
<span class="me-1">Title:</span>
|
||||||
|
<field name="meta_title_length" class="me-1"/>
|
||||||
|
<span class="text-muted">/60</span>
|
||||||
|
<span class="o_meta_indicator ms-1"
|
||||||
|
invisible="meta_title_status != 'optimal'">
|
||||||
|
<i class="fa fa-check-circle text-success" title="Optimal length"/>
|
||||||
|
</span>
|
||||||
|
<span class="o_meta_indicator ms-1"
|
||||||
|
invisible="meta_title_status != 'short'">
|
||||||
|
<i class="fa fa-exclamation-circle text-warning" title="Too short"/>
|
||||||
|
</span>
|
||||||
|
<span class="o_meta_indicator ms-1"
|
||||||
|
invisible="meta_title_status != 'long'">
|
||||||
|
<i class="fa fa-exclamation-triangle text-danger" title="Too long"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="fa fa-file-text-o me-1"/>
|
||||||
|
<span class="me-1">Desc:</span>
|
||||||
|
<field name="meta_desc_length" class="me-1"/>
|
||||||
|
<span class="text-muted">/155</span>
|
||||||
|
<span class="o_meta_indicator ms-1"
|
||||||
|
invisible="meta_desc_status != 'optimal'">
|
||||||
|
<i class="fa fa-check-circle text-success" title="Optimal length"/>
|
||||||
|
</span>
|
||||||
|
<span class="o_meta_indicator ms-1"
|
||||||
|
invisible="meta_desc_status != 'short'">
|
||||||
|
<i class="fa fa-exclamation-circle text-warning" title="Too short"/>
|
||||||
|
</span>
|
||||||
|
<span class="o_meta_indicator ms-1"
|
||||||
|
invisible="meta_desc_status != 'long'">
|
||||||
|
<i class="fa fa-exclamation-triangle text-danger" title="Too long"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="fa fa-font me-1"/>
|
||||||
|
<span class="me-1">Words:</span>
|
||||||
|
<field name="word_count"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2" invisible="not seo_improvements">
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="fa fa-lightbulb-o me-1" title="Suggestions"/>
|
||||||
|
<field name="seo_improvements" class="d-inline" widget="text" readonly="1"/>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<group string="Generated teaser/excerpt">
|
||||||
|
<field name="generated_teaser" nolabel="1" colspan="2" placeholder="Generated teaser/excerpt"/>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page string="SEO Metadata" name="seo">
|
||||||
|
<group>
|
||||||
|
<group string="Meta Tags">
|
||||||
|
<field name="website_meta_title"
|
||||||
|
placeholder="Max 60 characters recommended"/>
|
||||||
|
<field name="website_meta_description"
|
||||||
|
placeholder="Max 155 characters recommended"/>
|
||||||
|
<field name="website_meta_keywords"/>
|
||||||
|
</group>
|
||||||
|
<group string="URL & Keywords">
|
||||||
|
<field name="seo_name" placeholder="url-friendly-name"/>
|
||||||
|
<field name="generated_keywords"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page string="SEO Analysis" name="seo_analysis">
|
||||||
|
<field name="seo_analysis_html" nolabel="1"/>
|
||||||
|
<group string="Quick Improvement List" invisible="not seo_improvements">
|
||||||
|
<div class="alert alert-light border" role="alert" colspan="2">
|
||||||
|
<strong><i class="fa fa-lightbulb-o"></i> Suggestions to improve your SEO score:</strong>
|
||||||
|
<field name="seo_improvements" nolabel="1" class="mt-2"
|
||||||
|
widget="text" readonly="1" style="white-space: pre-line;"/>
|
||||||
|
</div>
|
||||||
|
</group>
|
||||||
|
<group string="Raw Analysis Data (Debug)" invisible="1">
|
||||||
|
<field name="seo_analysis" readonly="True" widget="json_pretty" nolabel="1"/>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page string="Images" name="images">
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="requested_image_count"
|
||||||
|
readonly="state != 'draft'"/>
|
||||||
|
<field name="image_style"
|
||||||
|
readonly="state != 'draft'"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="images_pending" invisible="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<field name="image_ids">
|
||||||
|
<list editable="bottom">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="image" widget="image"
|
||||||
|
options="{'size': [120, 120]}"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="style"/>
|
||||||
|
<field name="state" widget="badge"
|
||||||
|
decoration-info="state == 'draft'"
|
||||||
|
decoration-warning="state in ('pending', 'processing')"
|
||||||
|
decoration-success="state == 'done'"
|
||||||
|
decoration-danger="state == 'error'"/>
|
||||||
|
<field name="is_cover" string="Cover"
|
||||||
|
widget="boolean_toggle"/>
|
||||||
|
<field name="is_og_image" string="OG"
|
||||||
|
widget="boolean_toggle"/>
|
||||||
|
<field name="alt_text"/>
|
||||||
|
<button name="action_generate" type="object"
|
||||||
|
string="Generate" icon="fa-magic"
|
||||||
|
invisible="state != 'draft'"/>
|
||||||
|
<button name="action_retry" type="object"
|
||||||
|
string="Retry" icon="fa-refresh"
|
||||||
|
invisible="state != 'error'"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page string="Source" name="source">
|
||||||
|
<group>
|
||||||
|
<group string="Topic">
|
||||||
|
<field name="source_topic" placeholder="Describe what the content should be about..."
|
||||||
|
readonly="state != 'draft'"/>
|
||||||
|
</group>
|
||||||
|
<group string="Source">
|
||||||
|
<field name="source_product_id"
|
||||||
|
readonly="state != 'draft'"/>
|
||||||
|
<field name="source_text" placeholder="Paste existing content to optimize..."
|
||||||
|
readonly="state != 'draft'"/>
|
||||||
|
<field name="reference_ids" widget="many2many_tags"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
<page string="Publishing" name="publishing">
|
||||||
|
<group>
|
||||||
|
<group string="Blog Settings">
|
||||||
|
<field name="blog_id"/>
|
||||||
|
<field name="blog_post_id" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<group string="Tracking">
|
||||||
|
<field name="token_cost"/>
|
||||||
|
<field name="generation_time"/>
|
||||||
|
<field name="api_task_id" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<group string="Content Freshness">
|
||||||
|
<field name="last_refreshed_date"/>
|
||||||
|
<field name="refresh_threshold_days"/>
|
||||||
|
<field name="days_since_refresh" readonly="1"/>
|
||||||
|
<field name="is_stale" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Error" invisible="not error_message">
|
||||||
|
<field name="error_message" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
<chatter/>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- SEO Content Search View -->
|
||||||
|
<record id="otk_seo_content_view_search" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.search</field>
|
||||||
|
<field name="model">otk.seo.content</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search SEO Content">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="source_keywords"/>
|
||||||
|
<field name="generated_title"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="filter_draft" string="Draft"
|
||||||
|
domain="[('state', '=', 'draft')]"/>
|
||||||
|
<filter name="filter_review" string="In Review"
|
||||||
|
domain="[('state', '=', 'review')]"/>
|
||||||
|
<filter name="filter_approved" string="Approved"
|
||||||
|
domain="[('state', '=', 'approved')]"/>
|
||||||
|
<filter name="filter_published" string="Published"
|
||||||
|
domain="[('state', '=', 'published')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="filter_blog_post" string="Blog Posts"
|
||||||
|
domain="[('content_type', '=', 'blog_post')]"/>
|
||||||
|
<filter name="filter_product_desc" string="Product Descriptions"
|
||||||
|
domain="[('content_type', '=', 'product_desc')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="filter_high_seo" string="High SEO Score (70+)"
|
||||||
|
domain="[('seo_score', '>=', 70)]"/>
|
||||||
|
<filter name="filter_needs_work" string="Needs SEO Work (<50)"
|
||||||
|
domain="[('seo_score', '<', 50)]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="filter_my_content" string="My Content"
|
||||||
|
domain="[('create_uid', '=', uid)]"/>
|
||||||
|
<filter name="filter_stale" string="Needs Refresh"
|
||||||
|
domain="[('is_stale', '=', True)]"/>
|
||||||
|
<group>
|
||||||
|
<filter name="group_state" string="Status"
|
||||||
|
context="{'group_by': 'state'}"/>
|
||||||
|
<filter name="group_type" string="Content Type"
|
||||||
|
context="{'group_by': 'content_type'}"/>
|
||||||
|
<filter name="group_language" string="Language"
|
||||||
|
context="{'group_by': 'language_id'}"/>
|
||||||
|
<filter name="group_template" string="Template"
|
||||||
|
context="{'group_by': 'template_id'}"/>
|
||||||
|
<filter name="group_create_date" string="Created"
|
||||||
|
context="{'group_by': 'create_date:month'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- SEO Content Kanban View -->
|
||||||
|
<record id="otk_seo_content_view_kanban" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.kanban</field>
|
||||||
|
<field name="model">otk.seo.content</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<kanban default_group_by="state" class="o_kanban_small_column">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="content_type"/>
|
||||||
|
<field name="seo_score"/>
|
||||||
|
<field name="state"/>
|
||||||
|
<field name="image_count"/>
|
||||||
|
<templates>
|
||||||
|
<t t-name="card">
|
||||||
|
<div class="oe_kanban_card oe_kanban_global_click">
|
||||||
|
<div class="oe_kanban_content">
|
||||||
|
<div class="o_kanban_record_title">
|
||||||
|
<strong><field name="name"/></strong>
|
||||||
|
</div>
|
||||||
|
<div class="o_kanban_record_subtitle">
|
||||||
|
<field name="content_type"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_kanban_record_bottom">
|
||||||
|
<div class="oe_kanban_bottom_left">
|
||||||
|
<span title="SEO Score">
|
||||||
|
<i class="fa fa-line-chart"/> <field name="seo_score"/>%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="oe_kanban_bottom_right">
|
||||||
|
<span title="Images">
|
||||||
|
<i class="fa fa-image"/> <field name="image_count"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
</kanban>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Content Version List View -->
|
||||||
|
<record id="otk_seo_content_version_view_tree" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.version.list</field>
|
||||||
|
<field name="model">otk.seo.content.version</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Version History">
|
||||||
|
<field name="version_number"/>
|
||||||
|
<field name="note"/>
|
||||||
|
<field name="generated_title"/>
|
||||||
|
<field name="created_by"/>
|
||||||
|
<field name="created_at"/>
|
||||||
|
<button name="action_view_diff" type="object"
|
||||||
|
string="View Diff" icon="fa-exchange"
|
||||||
|
class="btn-link"/>
|
||||||
|
<button name="action_restore" type="object"
|
||||||
|
string="Restore" icon="fa-undo"
|
||||||
|
confirm="Restore this version? Current content will be saved as a new version."/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Content Version Diff View (Dialog) -->
|
||||||
|
<record id="otk_seo_content_version_view_diff" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.version.diff</field>
|
||||||
|
<field name="model">otk.seo.content.version</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Version Diff">
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="version_number" readonly="1"/>
|
||||||
|
<field name="note" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="created_by" readonly="1"/>
|
||||||
|
<field name="created_at" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<separator string="Changes from this version to current content"/>
|
||||||
|
<field name="diff_preview" nolabel="1" readonly="1"/>
|
||||||
|
</sheet>
|
||||||
|
<footer>
|
||||||
|
<button name="action_restore" type="object"
|
||||||
|
string="Restore This Version" class="btn-primary"
|
||||||
|
confirm="Restore this version? Current content will be saved as a new version."/>
|
||||||
|
<button string="Close" class="btn-secondary" special="cancel"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Action -->
|
||||||
|
<record id="action_otk_seo_content" model="ir.actions.act_window">
|
||||||
|
<field name="name">SEO Content</field>
|
||||||
|
<field name="res_model">otk.seo.content</field>
|
||||||
|
<field name="view_mode">list,kanban,form</field>
|
||||||
|
<field name="search_view_id" ref="otk_seo_content_view_search"/>
|
||||||
|
<field name="context">{}</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Create your first SEO content!
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Generate AI-powered blog posts, product descriptions, and more
|
||||||
|
with built-in SEO optimization and image generation.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- OWL Dashboard Client Action (new xmlid - old act_window used action_seo_content_dashboard) -->
|
||||||
|
<record id="action_seo_content_dashboard_owl" model="ir.actions.client">
|
||||||
|
<field name="name">SEO Content Dashboard</field>
|
||||||
|
<field name="tag">seo_content_dashboard</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Keep act_window for pivot/graph reports -->
|
||||||
|
<record id="action_seo_content_reports" model="ir.actions.act_window">
|
||||||
|
<field name="name">SEO Content Reports</field>
|
||||||
|
<field name="res_model">otk.seo.content</field>
|
||||||
|
<field name="view_mode">pivot,graph,list</field>
|
||||||
|
<field name="context">{'search_default_filter_my_content': 1}</field>
|
||||||
|
</record>
|
||||||
|
<!-- Graph View - Content by Type -->
|
||||||
|
<record id="otk_seo_content_view_graph_type" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.graph.type</field>
|
||||||
|
<field name="model">otk.seo.content</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<graph string="Content by Type" type="pie">
|
||||||
|
<field name="content_type"/>
|
||||||
|
</graph>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Graph View - Content by State -->
|
||||||
|
<record id="otk_seo_content_view_graph_state" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.graph.state</field>
|
||||||
|
<field name="model">otk.seo.content</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<graph string="Content by Status" type="bar">
|
||||||
|
<field name="state"/>
|
||||||
|
</graph>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Graph View - Token Usage Over Time -->
|
||||||
|
<record id="otk_seo_content_view_graph_tokens" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.graph.tokens</field>
|
||||||
|
<field name="model">otk.seo.content</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<graph string="Token Usage Over Time" type="line">
|
||||||
|
<field name="create_date" interval="month"/>
|
||||||
|
<field name="token_cost" type="measure"/>
|
||||||
|
</graph>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Graph View - SEO Score Distribution -->
|
||||||
|
<record id="otk_seo_content_view_graph_seo" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.graph.seo</field>
|
||||||
|
<field name="model">otk.seo.content</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<graph string="SEO Score Distribution" type="bar">
|
||||||
|
<field name="seo_score"/>
|
||||||
|
</graph>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Pivot View -->
|
||||||
|
<record id="otk_seo_content_view_pivot" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.pivot</field>
|
||||||
|
<field name="model">otk.seo.content</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<pivot string="Content Analysis">
|
||||||
|
<field name="content_type" type="row"/>
|
||||||
|
<field name="state" type="col"/>
|
||||||
|
<field name="token_cost" type="measure"/>
|
||||||
|
<field name="word_count" type="measure"/>
|
||||||
|
<field name="seo_score" type="measure"/>
|
||||||
|
</pivot>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Graph Action - Token Usage -->
|
||||||
|
<record id="action_seo_content_token_usage" model="ir.actions.act_window">
|
||||||
|
<field name="name">Token Usage</field>
|
||||||
|
<field name="res_model">otk.seo.content</field>
|
||||||
|
<field name="view_mode">pivot,graph,list</field>
|
||||||
|
<field name="view_id" ref="otk_seo_content_view_graph_tokens"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Graph Action - Content by Type -->
|
||||||
|
<record id="action_seo_content_by_type" model="ir.actions.act_window">
|
||||||
|
<field name="name">Content by Type</field>
|
||||||
|
<field name="res_model">otk.seo.content</field>
|
||||||
|
<field name="view_mode">pivot,graph,list</field>
|
||||||
|
<field name="view_id" ref="otk_seo_content_view_graph_type"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Graph Action - Content by State -->
|
||||||
|
<record id="action_seo_content_by_state" model="ir.actions.act_window">
|
||||||
|
<field name="name">Content by Status</field>
|
||||||
|
<field name="res_model">otk.seo.content</field>
|
||||||
|
<field name="view_mode">pivot,graph,list</field>
|
||||||
|
<field name="view_id" ref="otk_seo_content_view_graph_state"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- Reference Tag List View -->
|
||||||
|
<record id="otk_seo_reference_tag_view_tree" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.reference.tag.list</field>
|
||||||
|
<field name="model">otk.seo.reference.tag</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Reference Tags" editable="bottom">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="color" widget="color_picker"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Reference List View -->
|
||||||
|
<record id="otk_seo_reference_view_tree" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.reference.list</field>
|
||||||
|
<field name="model">otk.seo.reference</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Reference Library"
|
||||||
|
decoration-danger="reference_type == 'url' and url_fetch_status == 'error'">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="reference_type" widget="badge"
|
||||||
|
decoration-info="reference_type == 'internal_content'"
|
||||||
|
decoration-success="reference_type == 'url'"
|
||||||
|
decoration-warning="reference_type == 'text'"
|
||||||
|
decoration-muted="reference_type == 'file'"/>
|
||||||
|
<field name="url_fetch_status" string="URL Status" widget="badge"
|
||||||
|
decoration-info="url_fetch_status == 'pending'"
|
||||||
|
decoration-warning="url_fetch_status == 'fetching'"
|
||||||
|
decoration-success="url_fetch_status == 'success'"
|
||||||
|
decoration-danger="url_fetch_status == 'error'"
|
||||||
|
optional="show"/>
|
||||||
|
<field name="content_type"/>
|
||||||
|
<field name="tag_ids" widget="many2many_tags"
|
||||||
|
options="{'color_field': 'color'}"/>
|
||||||
|
<field name="content_length"/>
|
||||||
|
<field name="active" column_invisible="1"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Reference Form View -->
|
||||||
|
<record id="otk_seo_reference_view_form" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.reference.form</field>
|
||||||
|
<field name="model">otk.seo.reference</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Reference">
|
||||||
|
<sheet>
|
||||||
|
<widget name="web_ribbon" title="Archived"
|
||||||
|
bg_color="text-bg-danger" invisible="active"/>
|
||||||
|
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1>
|
||||||
|
<field name="name" placeholder="Reference Name"/>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<group string="Reference Settings">
|
||||||
|
<field name="reference_type" widget="radio"
|
||||||
|
options="{'horizontal': true}"/>
|
||||||
|
<field name="content_type"/>
|
||||||
|
<field name="sequence"/>
|
||||||
|
<field name="active" invisible="1"/>
|
||||||
|
</group>
|
||||||
|
<group string="Organization">
|
||||||
|
<field name="tag_ids" widget="many2many_tags"
|
||||||
|
options="{'color_field': 'color', 'no_create_edit': False}"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group string="Reference Content">
|
||||||
|
<!-- Internal Content -->
|
||||||
|
<field name="internal_content_id"
|
||||||
|
invisible="reference_type != 'internal_content'"
|
||||||
|
required="reference_type == 'internal_content'"
|
||||||
|
options="{'no_create': True}"/>
|
||||||
|
|
||||||
|
<!-- URL -->
|
||||||
|
<field name="url" widget="url"
|
||||||
|
invisible="reference_type != 'url'"
|
||||||
|
required="reference_type == 'url'"
|
||||||
|
placeholder="https://example.com/article"/>
|
||||||
|
|
||||||
|
<!-- URL Fetch Status -->
|
||||||
|
<div invisible="reference_type != 'url'" class="d-flex align-items-center gap-2 mb-2" colspan="2">
|
||||||
|
<field name="url_fetch_status" widget="badge"
|
||||||
|
decoration-info="url_fetch_status == 'pending'"
|
||||||
|
decoration-warning="url_fetch_status == 'fetching'"
|
||||||
|
decoration-success="url_fetch_status == 'success'"
|
||||||
|
decoration-danger="url_fetch_status == 'error'"/>
|
||||||
|
<field name="url_fetch_date" readonly="1" class="text-muted"/>
|
||||||
|
<button name="action_fetch_url_content" type="object"
|
||||||
|
string="Refresh Content" icon="fa-refresh"
|
||||||
|
class="btn-sm btn-secondary"
|
||||||
|
invisible="not url"/>
|
||||||
|
</div>
|
||||||
|
<field name="url_fetch_error" readonly="1"
|
||||||
|
invisible="reference_type != 'url' or url_fetch_status != 'error'"
|
||||||
|
class="text-danger"/>
|
||||||
|
<field name="url_content_type" readonly="1"
|
||||||
|
invisible="reference_type != 'url' or url_fetch_status != 'success'"/>
|
||||||
|
<field name="url_fetched_content" readonly="1"
|
||||||
|
invisible="reference_type != 'url' or url_fetch_status != 'success'"
|
||||||
|
placeholder="Content will be fetched from URL automatically..."/>
|
||||||
|
|
||||||
|
<!-- Text -->
|
||||||
|
<field name="text_content"
|
||||||
|
invisible="reference_type != 'text'"
|
||||||
|
required="reference_type == 'text'"
|
||||||
|
placeholder="Paste reference text content here..."/>
|
||||||
|
|
||||||
|
<!-- File -->
|
||||||
|
<field name="file" filename="file_name"
|
||||||
|
invisible="reference_type != 'file'"
|
||||||
|
required="reference_type == 'file'"/>
|
||||||
|
<field name="file_name" invisible="1"/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group string="Description">
|
||||||
|
<field name="description" nolabel="1" colspan="2"
|
||||||
|
placeholder="Brief description of this reference and when to use it..."/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group string="Content Preview" invisible="not content_preview">
|
||||||
|
<field name="content_length" string="Content Length (chars)"/>
|
||||||
|
<field name="content_preview" nolabel="1" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Reference Search View -->
|
||||||
|
<record id="otk_seo_reference_view_search" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.reference.search</field>
|
||||||
|
<field name="model">otk.seo.reference</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search References">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="description"/>
|
||||||
|
<field name="tag_ids"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="filter_internal" string="Internal Content"
|
||||||
|
domain="[('reference_type', '=', 'internal_content')]"/>
|
||||||
|
<filter name="filter_url" string="URLs"
|
||||||
|
domain="[('reference_type', '=', 'url')]"/>
|
||||||
|
<filter name="filter_url_error" string="URL Fetch Errors"
|
||||||
|
domain="[('reference_type', '=', 'url'), ('url_fetch_status', '=', 'error')]"/>
|
||||||
|
<filter name="filter_text" string="Text"
|
||||||
|
domain="[('reference_type', '=', 'text')]"/>
|
||||||
|
<filter name="filter_file" string="Files"
|
||||||
|
domain="[('reference_type', '=', 'file')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="filter_blog" string="For Blog Posts"
|
||||||
|
domain="['|', ('content_type', '=', 'blog_post'), ('content_type', '=', 'all')]"/>
|
||||||
|
<filter name="filter_product" string="For Products"
|
||||||
|
domain="['|', ('content_type', '=', 'product_desc'), ('content_type', '=', 'all')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="filter_active" string="Active"
|
||||||
|
domain="[('active', '=', True)]"/>
|
||||||
|
<filter name="filter_archived" string="Archived"
|
||||||
|
domain="[('active', '=', False)]"/>
|
||||||
|
<group>
|
||||||
|
<filter name="group_type" string="Type"
|
||||||
|
context="{'group_by': 'reference_type'}"/>
|
||||||
|
<filter name="group_content_type" string="Content Type"
|
||||||
|
context="{'group_by': 'content_type'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<record id="action_otk_seo_reference" model="ir.actions.act_window">
|
||||||
|
<field name="name">Reference Library</field>
|
||||||
|
<field name="res_model">otk.seo.reference</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="search_view_id" ref="otk_seo_reference_view_search"/>
|
||||||
|
<field name="context">{'search_default_filter_active': 1}</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Create your first reference!
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Build a library of reference materials that AI can use
|
||||||
|
when generating content. Include URLs, text snippets,
|
||||||
|
existing content, or uploaded documents.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_otk_seo_reference_tag" model="ir.actions.act_window">
|
||||||
|
<field name="name">Reference Tags</field>
|
||||||
|
<field name="res_model">otk.seo.reference.tag</field>
|
||||||
|
<field name="view_mode">list</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Create tags to organize references!
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- SEO Template List View -->
|
||||||
|
<record id="otk_seo_template_view_tree" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.template.list</field>
|
||||||
|
<field name="model">otk.seo.template</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Content Templates">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="content_type"/>
|
||||||
|
<field name="default_tone"/>
|
||||||
|
<field name="default_word_count"/>
|
||||||
|
<field name="include_images" widget="boolean_toggle"/>
|
||||||
|
<field name="usage_count"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- SEO Template Form View -->
|
||||||
|
<record id="otk_seo_template_view_form" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.template.form</field>
|
||||||
|
<field name="model">otk.seo.template</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Content Template">
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_button_box" name="button_box">
|
||||||
|
<button name="action_view_contents" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-file-text-o">
|
||||||
|
<field name="usage_count" widget="statinfo" string="Uses"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<widget name="web_ribbon" title="Archived"
|
||||||
|
bg_color="text-bg-danger" invisible="active"/>
|
||||||
|
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1>
|
||||||
|
<field name="name" placeholder="Template Name"/>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<group string="Basic Settings">
|
||||||
|
<field name="content_type"/>
|
||||||
|
<field name="sequence"/>
|
||||||
|
<field name="active"/>
|
||||||
|
</group>
|
||||||
|
<group string="Defaults">
|
||||||
|
<field name="default_tone"/>
|
||||||
|
<field name="default_word_count"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<field name="description" placeholder="Describe when to use this template..."/>
|
||||||
|
|
||||||
|
<notebook>
|
||||||
|
<page string="Prompts" name="prompts">
|
||||||
|
<group string="System Prompt">
|
||||||
|
<field name="system_prompt" nolabel="1" colspan="2"
|
||||||
|
placeholder="Instructions for the AI about style, format, constraints...
|
||||||
|
|
||||||
|
Example:
|
||||||
|
You are an expert content writer specializing in {industry}.
|
||||||
|
Write in a {tone} style, using clear and concise language.
|
||||||
|
Structure the content with clear headers and bullet points where appropriate."/>
|
||||||
|
</group>
|
||||||
|
<group string="Content Prompt Template">
|
||||||
|
<field name="content_prompt_template" nolabel="1" colspan="2"
|
||||||
|
placeholder="Template for the main content.
|
||||||
|
|
||||||
|
Available placeholders:
|
||||||
|
- {keywords} - Target keywords
|
||||||
|
- {topic} - Topic description
|
||||||
|
- {product_name} - Product name (if applicable)
|
||||||
|
- {tone} - Selected tone
|
||||||
|
- {word_count} - Target word count
|
||||||
|
- {language} - Target language
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Write a comprehensive {word_count} word blog post about {topic}.
|
||||||
|
Target keywords: {keywords}
|
||||||
|
Tone: {tone}"/>
|
||||||
|
</group>
|
||||||
|
<group string="Meta Description Prompt">
|
||||||
|
<field name="meta_prompt_template" nolabel="1" colspan="2"
|
||||||
|
placeholder="Template for generating meta description..."/>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page string="Image Settings" name="images">
|
||||||
|
<group>
|
||||||
|
<group string="Image Generation">
|
||||||
|
<field name="include_images" widget="boolean_toggle"/>
|
||||||
|
<field name="default_image_count"
|
||||||
|
invisible="not include_images"/>
|
||||||
|
<field name="default_image_style"
|
||||||
|
invisible="not include_images"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Image Prompt Template" invisible="not include_images">
|
||||||
|
<field name="image_prompt_template" nolabel="1" colspan="2"
|
||||||
|
placeholder="Template for image generation.
|
||||||
|
|
||||||
|
Available placeholders:
|
||||||
|
- {title} - Content title
|
||||||
|
- {keywords} - Target keywords
|
||||||
|
- {section} - Section being illustrated
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Professional photograph illustrating {title}.
|
||||||
|
Style: Modern, clean, business-appropriate.
|
||||||
|
Related to: {keywords}"/>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page string="Formatting" name="formatting">
|
||||||
|
<group>
|
||||||
|
<group string="Content Structure">
|
||||||
|
<field name="header_structure"/>
|
||||||
|
<field name="include_toc"/>
|
||||||
|
<field name="include_faq"/>
|
||||||
|
<field name="include_cta"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page string="References" name="references">
|
||||||
|
<group>
|
||||||
|
<field name="reference_ids" widget="many2many_tags"
|
||||||
|
domain="['|', ('content_type', '=', content_type), ('content_type', '=', 'all')]"
|
||||||
|
placeholder="Select default references for this template..."/>
|
||||||
|
</group>
|
||||||
|
<p class="text-muted">
|
||||||
|
References selected here will be automatically added when users
|
||||||
|
select this template. They can still add or remove references before generating.
|
||||||
|
</p>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- SEO Template Search View -->
|
||||||
|
<record id="otk_seo_template_view_search" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.template.search</field>
|
||||||
|
<field name="model">otk.seo.template</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Templates">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="content_type"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="filter_blog" string="Blog Post"
|
||||||
|
domain="[('content_type', '=', 'blog_post')]"/>
|
||||||
|
<filter name="filter_product" string="Product Description"
|
||||||
|
domain="[('content_type', '=', 'product_desc')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="filter_active" string="Active"
|
||||||
|
domain="[('active', '=', True)]"/>
|
||||||
|
<filter name="filter_archived" string="Archived"
|
||||||
|
domain="[('active', '=', False)]"/>
|
||||||
|
<group>
|
||||||
|
<filter name="group_type" string="Content Type"
|
||||||
|
context="{'group_by': 'content_type'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Action -->
|
||||||
|
<record id="action_otk_seo_template" model="ir.actions.act_window">
|
||||||
|
<field name="name">Content Templates</field>
|
||||||
|
<field name="res_model">otk.seo.template</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="search_view_id" ref="otk_seo_template_view_search"/>
|
||||||
|
<field name="context">{'search_default_filter_active': 1}</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Create your first content template!
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Templates help maintain consistent style and formatting
|
||||||
|
across your generated content.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import generate_content_wizard
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- Generate Content Wizard Form View (Multi-Step) -->
|
||||||
|
<record id="otk_seo_content_generate_wizard_view_form" model="ir.ui.view">
|
||||||
|
<field name="name">otk.seo.content.generate.wizard.form</field>
|
||||||
|
<field name="model">otk.seo.content.generate.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Generate SEO Content">
|
||||||
|
<!-- Step Indicator -->
|
||||||
|
<div class="otk_wizard_steps d-flex justify-content-center align-items-center gap-2 mb-4 px-3 py-3 bg-light rounded">
|
||||||
|
<!-- Step 1: Source — Active -->
|
||||||
|
<div class="d-flex align-items-center gap-2" invisible="state != 'step_source'">
|
||||||
|
<span class="otk_step_circle otk_step_active">1</span>
|
||||||
|
<span class="fw-bold text-primary">Source</span>
|
||||||
|
</div>
|
||||||
|
<!-- Step 1: Source — Completed -->
|
||||||
|
<div class="d-flex align-items-center gap-2" invisible="state == 'step_source'">
|
||||||
|
<span class="otk_step_circle otk_step_done">
|
||||||
|
<i class="fa fa-check" title="Completed"/>
|
||||||
|
</span>
|
||||||
|
<span class="text-success">Source</span>
|
||||||
|
</div>
|
||||||
|
<i class="fa fa-chevron-right text-muted small" title="next step"/>
|
||||||
|
|
||||||
|
<!-- Step 2: Settings — Active -->
|
||||||
|
<div class="d-flex align-items-center gap-2" invisible="state != 'step_settings'">
|
||||||
|
<span class="otk_step_circle otk_step_active">2</span>
|
||||||
|
<span class="fw-bold text-primary">Settings</span>
|
||||||
|
</div>
|
||||||
|
<!-- Step 2: Settings — Completed -->
|
||||||
|
<div class="d-flex align-items-center gap-2" invisible="state not in ('step_images', 'step_review')">
|
||||||
|
<span class="otk_step_circle otk_step_done">
|
||||||
|
<i class="fa fa-check" title="Completed"/>
|
||||||
|
</span>
|
||||||
|
<span class="text-success">Settings</span>
|
||||||
|
</div>
|
||||||
|
<!-- Step 2: Settings — Upcoming -->
|
||||||
|
<div class="d-flex align-items-center gap-2" invisible="state != 'step_source'">
|
||||||
|
<span class="otk_step_circle otk_step_upcoming">2</span>
|
||||||
|
<span class="text-muted">Settings</span>
|
||||||
|
</div>
|
||||||
|
<i class="fa fa-chevron-right text-muted small" title="next step"/>
|
||||||
|
|
||||||
|
<!-- Step 3: Images — Active -->
|
||||||
|
<div class="d-flex align-items-center gap-2" invisible="state != 'step_images'">
|
||||||
|
<span class="otk_step_circle otk_step_active">3</span>
|
||||||
|
<span class="fw-bold text-primary">Images</span>
|
||||||
|
</div>
|
||||||
|
<!-- Step 3: Images — Completed -->
|
||||||
|
<div class="d-flex align-items-center gap-2" invisible="state != 'step_review'">
|
||||||
|
<span class="otk_step_circle otk_step_done">
|
||||||
|
<i class="fa fa-check" title="Completed"/>
|
||||||
|
</span>
|
||||||
|
<span class="text-success">Images</span>
|
||||||
|
</div>
|
||||||
|
<!-- Step 3: Images — Upcoming -->
|
||||||
|
<div class="d-flex align-items-center gap-2" invisible="state not in ('step_source', 'step_settings')">
|
||||||
|
<span class="otk_step_circle otk_step_upcoming">3</span>
|
||||||
|
<span class="text-muted">Images</span>
|
||||||
|
</div>
|
||||||
|
<i class="fa fa-chevron-right text-muted small" title="next step"/>
|
||||||
|
|
||||||
|
<!-- Step 4: Review — Active -->
|
||||||
|
<div class="d-flex align-items-center gap-2" invisible="state != 'step_review'">
|
||||||
|
<span class="otk_step_circle otk_step_active">4</span>
|
||||||
|
<span class="fw-bold text-primary">Review</span>
|
||||||
|
</div>
|
||||||
|
<!-- Step 4: Review — Upcoming -->
|
||||||
|
<div class="d-flex align-items-center gap-2" invisible="state == 'step_review'">
|
||||||
|
<span class="otk_step_circle otk_step_upcoming">4</span>
|
||||||
|
<span class="text-muted">Review</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<field name="state" invisible="1"/>
|
||||||
|
|
||||||
|
<!-- ==================== STEP 1: Source ==================== -->
|
||||||
|
<group invisible="state != 'step_source'">
|
||||||
|
<group string="Content Type">
|
||||||
|
<field name="content_type" widget="radio"/>
|
||||||
|
<field name="source_type" widget="radio"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group string="Source" invisible="state != 'step_source' or source_type == 'product'">
|
||||||
|
<field name="source_keywords"
|
||||||
|
placeholder="keyword1, keyword2, keyword3..."
|
||||||
|
invisible="source_type == 'existing'"/>
|
||||||
|
<field name="source_topic"
|
||||||
|
placeholder="Describe what the content should be about..."
|
||||||
|
invisible="source_type == 'existing'"/>
|
||||||
|
<field name="source_text"
|
||||||
|
placeholder="Paste existing content to optimize..."
|
||||||
|
invisible="source_type != 'existing'"/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group string="Product" invisible="state != 'step_source' or source_type != 'product'">
|
||||||
|
<field name="source_product_id"/>
|
||||||
|
<field name="source_keywords"
|
||||||
|
placeholder="Additional keywords (optional)"/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- ==================== STEP 2: Settings ==================== -->
|
||||||
|
<group invisible="state != 'step_settings'">
|
||||||
|
<group string="Template & Voice">
|
||||||
|
<field name="template_id"
|
||||||
|
domain="[('content_type', '=', content_type)]"
|
||||||
|
placeholder="Optional - select a template"/>
|
||||||
|
<field name="brand_voice_id"
|
||||||
|
placeholder="Select brand voice"/>
|
||||||
|
</group>
|
||||||
|
<group string="Content Settings">
|
||||||
|
<field name="tone"/>
|
||||||
|
<field name="target_word_count"/>
|
||||||
|
<field name="language_id"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group string="References" invisible="state != 'step_settings'">
|
||||||
|
<field name="reference_ids" widget="many2many_tags"
|
||||||
|
domain="['|', ('content_type', '=', content_type), ('content_type', '=', 'all')]"
|
||||||
|
placeholder="Select references to inspire the AI (max 5)..."
|
||||||
|
options="{'no_create': True}"/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- ==================== STEP 3: Images ==================== -->
|
||||||
|
<group invisible="state != 'step_images'">
|
||||||
|
<group string="Image Settings">
|
||||||
|
<field name="include_images" widget="boolean_toggle"/>
|
||||||
|
<field name="image_count"
|
||||||
|
invisible="not include_images"/>
|
||||||
|
<field name="image_style"
|
||||||
|
invisible="not include_images"/>
|
||||||
|
</group>
|
||||||
|
<group string="Advanced Options">
|
||||||
|
<field name="include_toc" widget="boolean_toggle"/>
|
||||||
|
<field name="include_faq" widget="boolean_toggle"/>
|
||||||
|
<field name="custom_instructions"
|
||||||
|
placeholder="Additional instructions for the AI..."/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- ==================== STEP 4: Review ==================== -->
|
||||||
|
<div invisible="state != 'step_review'" class="px-3">
|
||||||
|
<group string="Review Your Settings">
|
||||||
|
<group>
|
||||||
|
<field name="content_type" readonly="1"/>
|
||||||
|
<field name="source_type" readonly="1"/>
|
||||||
|
<field name="source_keywords" readonly="1"
|
||||||
|
invisible="not source_keywords"/>
|
||||||
|
<field name="template_id" readonly="1"/>
|
||||||
|
<field name="brand_voice_id" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="tone" readonly="1"/>
|
||||||
|
<field name="target_word_count" readonly="1"/>
|
||||||
|
<field name="language_id" readonly="1"/>
|
||||||
|
<field name="include_images" readonly="1"/>
|
||||||
|
<field name="image_count" readonly="1"
|
||||||
|
invisible="not include_images"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group string="Publishing">
|
||||||
|
<field name="blog_id" placeholder="Select target blog (optional)"/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- Cost Estimation Panel -->
|
||||||
|
<div class="otk_cost_panel alert alert-info mt-3" role="alert" title="Estimated Token Cost">
|
||||||
|
<h5><i class="fa fa-calculator me-1"/> Estimated Token Cost</h5>
|
||||||
|
<field name="cost_breakdown" readonly="1"
|
||||||
|
style="white-space: pre-line; font-family: monospace;"/>
|
||||||
|
<field name="estimated_cost" invisible="1"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<!-- Steps 1-3: Next button -->
|
||||||
|
<button name="action_prev_step" type="object"
|
||||||
|
string="Back" class="btn-secondary"
|
||||||
|
invisible="state == 'step_source'"/>
|
||||||
|
<button name="action_next_step" type="object"
|
||||||
|
string="Next" class="btn-primary"
|
||||||
|
invisible="state == 'step_review'"/>
|
||||||
|
<!-- Step 4: Generate button -->
|
||||||
|
<button name="action_generate" type="object"
|
||||||
|
string="Generate Content" class="btn-primary"
|
||||||
|
invisible="state != 'step_review'"/>
|
||||||
|
<!-- Steps 2-4: Back button -->
|
||||||
|
<button string="Cancel" special="cancel"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Action to open wizard -->
|
||||||
|
<record id="action_generate_content_wizard" model="ir.actions.act_window">
|
||||||
|
<field name="name">Generate Content</field>
|
||||||
|
<field name="res_model">otk.seo.content.generate.wizard</field>
|
||||||
|
<field name="view_mode">form</field>
|
||||||
|
<field name="target">new</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateContentWizard(models.TransientModel):
|
||||||
|
_name = 'otk.seo.content.generate.wizard'
|
||||||
|
_description = 'Generate SEO Content Wizard'
|
||||||
|
|
||||||
|
# === Wizard State ===
|
||||||
|
state = fields.Selection([
|
||||||
|
('step_source', 'Content & Source'),
|
||||||
|
('step_settings', 'Settings'),
|
||||||
|
('step_images', 'Images'),
|
||||||
|
('step_review', 'Review & Generate'),
|
||||||
|
], default='step_source', string='Step')
|
||||||
|
|
||||||
|
# === Step 1: Content Type & Source ===
|
||||||
|
content_type = fields.Selection([
|
||||||
|
('blog_post', 'Blog Post'),
|
||||||
|
('product_desc', 'Product Description'),
|
||||||
|
('category_desc', 'Category Description'),
|
||||||
|
('landing_page', 'Landing Page Content'),
|
||||||
|
], string='Content Type', required=True, default='blog_post',
|
||||||
|
help='Select the type of content you want to generate. Each type has optimized formatting and structure.')
|
||||||
|
|
||||||
|
source_type = fields.Selection([
|
||||||
|
('keywords', 'From Keywords/Topic'),
|
||||||
|
('product', 'From Product'),
|
||||||
|
('existing', 'Optimize Existing Content'),
|
||||||
|
], string='Source', default='keywords',
|
||||||
|
help='Choose how to provide input for content generation. Keywords/Topic: enter target keywords and a brief; From Product: use product data; Optimize Existing: improve existing text.')
|
||||||
|
|
||||||
|
source_keywords = fields.Char('Target Keywords',
|
||||||
|
help='Enter comma-separated keywords you want the content to rank for (e.g., "best coffee maker, home brewing, espresso machine").')
|
||||||
|
source_topic = fields.Text('Topic/Brief',
|
||||||
|
help='Describe what the content should cover. The more detail you provide, the better the AI can match your expectations.')
|
||||||
|
source_product_id = fields.Many2one('product.template', 'Source Product',
|
||||||
|
help='Select a product to generate content about. The AI will use the product name, description, and attributes.')
|
||||||
|
source_text = fields.Text('Existing Content',
|
||||||
|
help='Paste your existing content here. The AI will optimize it for SEO while maintaining the core message.')
|
||||||
|
|
||||||
|
# === Step 2: Settings ===
|
||||||
|
template_id = fields.Many2one('otk.seo.template', 'Content Template',
|
||||||
|
domain="[('content_type', '=', content_type)]",
|
||||||
|
help='Optional: Choose a template to apply predefined prompts, tone, and formatting. Leave empty for default behavior.')
|
||||||
|
|
||||||
|
brand_voice_id = fields.Many2one('otk.seo.brand.voice', 'Brand Voice',
|
||||||
|
default=lambda self: self._default_brand_voice(),
|
||||||
|
help='Select a brand voice to ensure the AI generates content matching your brand tone, style, and vocabulary.')
|
||||||
|
|
||||||
|
tone = fields.Selection([
|
||||||
|
('professional', 'Professional'),
|
||||||
|
('casual', 'Casual'),
|
||||||
|
('technical', 'Technical'),
|
||||||
|
('persuasive', 'Persuasive'),
|
||||||
|
('conversational', 'Conversational'),
|
||||||
|
], string='Tone', default='professional',
|
||||||
|
help='The writing style for your content. Professional for B2B; Casual for lifestyle; Technical for documentation; Persuasive for sales; Conversational for engaging blog posts.')
|
||||||
|
|
||||||
|
target_word_count = fields.Selection([
|
||||||
|
('short', 'Short (300-500 words)'),
|
||||||
|
('medium', 'Medium (500-1000 words)'),
|
||||||
|
('long', 'Long (1000-2000 words)'),
|
||||||
|
], string='Target Length', default='medium',
|
||||||
|
help='Approximate word count for the generated content. Longer content generally performs better for SEO but costs more tokens.')
|
||||||
|
|
||||||
|
language_id = fields.Many2one('res.lang', 'Language',
|
||||||
|
default=lambda self: self._default_language(),
|
||||||
|
help='The language in which the content will be written. Defaults to your Odoo user language.')
|
||||||
|
|
||||||
|
# === References ===
|
||||||
|
reference_ids = fields.Many2many('otk.seo.reference',
|
||||||
|
'otk_seo_wizard_reference_rel', 'wizard_id', 'reference_id',
|
||||||
|
string='References',
|
||||||
|
domain="['|', ('content_type', '=', content_type), ('content_type', '=', 'all')]",
|
||||||
|
help='Select reference materials to guide the AI. Max 5 references. These provide examples and inspiration for the generated content.')
|
||||||
|
|
||||||
|
# === Step 3: Image Settings ===
|
||||||
|
include_images = fields.Boolean('Generate Images', default=True,
|
||||||
|
help='Enable to have AI generate images for your content. Images will be created asynchronously.')
|
||||||
|
image_count = fields.Integer('Number of Images', default=1,
|
||||||
|
help='How many images to generate. The first image is typically used as the cover image.')
|
||||||
|
image_style = fields.Selection([
|
||||||
|
('photorealistic', 'Photorealistic'),
|
||||||
|
('illustration', 'Illustration'),
|
||||||
|
('digital_art', 'Digital Art'),
|
||||||
|
('watercolor', 'Watercolor'),
|
||||||
|
('sketch', 'Sketch'),
|
||||||
|
('3d_render', '3D Render'),
|
||||||
|
], string='Image Style', default='photorealistic',
|
||||||
|
help='Visual style for generated images.')
|
||||||
|
|
||||||
|
# === Advanced Options (visible in step 3) ===
|
||||||
|
include_toc = fields.Boolean('Include Table of Contents',
|
||||||
|
help='Add a table of contents at the beginning of the content. Recommended for longer articles.')
|
||||||
|
include_faq = fields.Boolean('Include FAQ Section',
|
||||||
|
help='Add a FAQ section at the end. Great for SEO as it can appear in featured snippets.')
|
||||||
|
custom_instructions = fields.Text('Custom Instructions',
|
||||||
|
help='Any additional instructions for the AI (e.g., "Focus on sustainability", "Include pricing comparisons", "Avoid technical jargon").')
|
||||||
|
|
||||||
|
# === Step 4: Target ===
|
||||||
|
blog_id = fields.Many2one('blog.blog', 'Target Blog',
|
||||||
|
help='Select which blog to publish to. Leave empty to choose later or use the default blog from settings.')
|
||||||
|
|
||||||
|
# === Cost Estimation ===
|
||||||
|
estimated_cost = fields.Integer('Estimated Tokens', compute='_compute_estimated_cost')
|
||||||
|
cost_breakdown = fields.Text('Cost Breakdown', compute='_compute_estimated_cost')
|
||||||
|
|
||||||
|
def _default_language(self):
|
||||||
|
lang_code = self.env.lang or 'en_US'
|
||||||
|
lang = self.env['res.lang']._lang_get(lang_code)
|
||||||
|
return lang.id if lang else False
|
||||||
|
|
||||||
|
def _default_brand_voice(self):
|
||||||
|
"""Return the default brand voice if one exists."""
|
||||||
|
return self.env['otk.seo.brand.voice'].search([('is_default', '=', True)], limit=1)
|
||||||
|
|
||||||
|
@api.depends('target_word_count', 'include_images', 'image_count')
|
||||||
|
def _compute_estimated_cost(self):
|
||||||
|
# Fetch dynamic pricing from API (cached 1h in ir.config_parameter)
|
||||||
|
pricing = self.env['otk.seo.content'].get_pricing_estimates()
|
||||||
|
text_map = pricing.get('text', {'short': 150, 'medium': 300, 'long': 600})
|
||||||
|
tokens_per_image = pricing.get('image', 150)
|
||||||
|
|
||||||
|
for record in self:
|
||||||
|
text_tokens = text_map.get(record.target_word_count or 'medium', text_map.get('medium', 300))
|
||||||
|
image_tokens = (record.image_count or 0) * tokens_per_image if record.include_images else 0
|
||||||
|
record.estimated_cost = text_tokens + image_tokens
|
||||||
|
lines = [f"Text generation: ~{text_tokens} tokens"]
|
||||||
|
if image_tokens:
|
||||||
|
lines.append(f"Image generation ({record.image_count} images): ~{image_tokens} tokens")
|
||||||
|
lines.append(f"Total estimate: ~{text_tokens + image_tokens} tokens")
|
||||||
|
record.cost_breakdown = '\n'.join(lines)
|
||||||
|
|
||||||
|
@api.onchange('template_id')
|
||||||
|
def _onchange_template_id(self):
|
||||||
|
if self.template_id:
|
||||||
|
self.tone = self.template_id.default_tone
|
||||||
|
self.target_word_count = self.template_id.default_word_count
|
||||||
|
self.include_images = self.template_id.include_images
|
||||||
|
self.image_count = self.template_id.default_image_count
|
||||||
|
self.image_style = self.template_id.default_image_style
|
||||||
|
self.include_toc = self.template_id.include_toc
|
||||||
|
self.include_faq = self.template_id.include_faq
|
||||||
|
# Inherit references from template
|
||||||
|
if self.template_id.reference_ids:
|
||||||
|
self.reference_ids = [(6, 0, self.template_id.reference_ids.ids)]
|
||||||
|
|
||||||
|
@api.onchange('source_type')
|
||||||
|
def _onchange_source_type(self):
|
||||||
|
if self.source_type == 'product':
|
||||||
|
self.content_type = 'product_desc'
|
||||||
|
elif self.source_type == 'existing':
|
||||||
|
self.content_type = 'blog_post'
|
||||||
|
|
||||||
|
@api.onchange('source_product_id')
|
||||||
|
def _onchange_source_product_id(self):
|
||||||
|
if self.source_product_id:
|
||||||
|
self.source_keywords = self.source_product_id.name
|
||||||
|
self.source_topic = self._prepare_product_brief()
|
||||||
|
|
||||||
|
def _prepare_product_brief(self):
|
||||||
|
"""Prepare product information for AI."""
|
||||||
|
if not self.source_product_id:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
product = self.source_product_id
|
||||||
|
brief = f"Product: {product.name}\n"
|
||||||
|
|
||||||
|
if product.description:
|
||||||
|
brief += f"Description: {product.description}\n"
|
||||||
|
if product.list_price:
|
||||||
|
brief += f"Price: {product.list_price}\n"
|
||||||
|
if product.categ_id:
|
||||||
|
brief += f"Category: {product.categ_id.complete_name}\n"
|
||||||
|
|
||||||
|
return brief
|
||||||
|
|
||||||
|
# === Step Navigation ===
|
||||||
|
def _validate_step_source(self):
|
||||||
|
"""Validate Step 1 inputs by source type."""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.source_type == 'keywords' and not self.source_keywords and not self.source_topic:
|
||||||
|
raise UserError(_("Please provide keywords or a topic description."))
|
||||||
|
if self.source_type == 'product' and not self.source_product_id:
|
||||||
|
raise UserError(_("Please select a product."))
|
||||||
|
if self.source_type == 'existing' and not self.source_text:
|
||||||
|
raise UserError(_("Please provide existing content to optimize."))
|
||||||
|
|
||||||
|
def action_next_step(self):
|
||||||
|
"""Validate current step and advance to next."""
|
||||||
|
self.ensure_one()
|
||||||
|
steps = ['step_source', 'step_settings', 'step_images', 'step_review']
|
||||||
|
current_idx = steps.index(self.state)
|
||||||
|
|
||||||
|
# Validate current step
|
||||||
|
if self.state == 'step_source':
|
||||||
|
self._validate_step_source()
|
||||||
|
|
||||||
|
if current_idx < len(steps) - 1:
|
||||||
|
self.state = steps[current_idx + 1]
|
||||||
|
|
||||||
|
return self._reopen_wizard()
|
||||||
|
|
||||||
|
def action_prev_step(self):
|
||||||
|
"""Go back one step."""
|
||||||
|
self.ensure_one()
|
||||||
|
steps = ['step_source', 'step_settings', 'step_images', 'step_review']
|
||||||
|
current_idx = steps.index(self.state)
|
||||||
|
|
||||||
|
if current_idx > 0:
|
||||||
|
self.state = steps[current_idx - 1]
|
||||||
|
|
||||||
|
return self._reopen_wizard()
|
||||||
|
|
||||||
|
def _reopen_wizard(self):
|
||||||
|
"""Reopen the wizard at its current step."""
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': self._name,
|
||||||
|
'res_id': self.id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'new',
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_generate(self):
|
||||||
|
"""Create content record and start generation (final step action)."""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
self._validate_step_source()
|
||||||
|
|
||||||
|
# Create content record
|
||||||
|
content_vals = {
|
||||||
|
'name': self._generate_initial_name(),
|
||||||
|
'content_type': self.content_type,
|
||||||
|
'source_keywords': self.source_keywords,
|
||||||
|
'source_topic': self.source_topic,
|
||||||
|
'source_product_id': self.source_product_id.id if self.source_product_id else False,
|
||||||
|
'source_text': self.source_text,
|
||||||
|
'template_id': self.template_id.id if self.template_id else False,
|
||||||
|
'brand_voice_id': self.brand_voice_id.id if self.brand_voice_id else False,
|
||||||
|
'tone': self.tone,
|
||||||
|
'target_word_count': self.target_word_count,
|
||||||
|
'language_id': self.language_id.id if self.language_id else False,
|
||||||
|
'requested_image_count': self.image_count if self.include_images else 0,
|
||||||
|
'image_style': self.image_style if self.include_images else False,
|
||||||
|
'blog_id': self.blog_id.id if self.blog_id else False,
|
||||||
|
'reference_ids': [(6, 0, self.reference_ids.ids)] if self.reference_ids else False,
|
||||||
|
'state': 'draft',
|
||||||
|
}
|
||||||
|
|
||||||
|
content = self.env['otk.seo.content'].create(content_vals)
|
||||||
|
|
||||||
|
# Start generation
|
||||||
|
content.action_generate()
|
||||||
|
|
||||||
|
# Return action to view the content
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'otk.seo.content',
|
||||||
|
'res_id': content.id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'current',
|
||||||
|
}
|
||||||
|
|
||||||
|
def _generate_initial_name(self):
|
||||||
|
"""Generate an initial name for the content."""
|
||||||
|
if self.source_keywords:
|
||||||
|
return self.source_keywords[:100]
|
||||||
|
elif self.source_topic:
|
||||||
|
return self.source_topic[:100]
|
||||||
|
elif self.source_product_id:
|
||||||
|
return f"Content for {self.source_product_id.name}"
|
||||||
|
else:
|
||||||
|
return _("New SEO Content")
|
||||||