Refactor: Move SEO content files to root of module

This commit is contained in:
2026-07-12 07:08:33 +02:00
parent 8e01c7c8ec
commit 45c65e24dd
99 changed files with 0 additions and 3739 deletions
+1
View File
@@ -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 &amp; 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")