This commit is contained in:
2026-07-11 01:28:32 +02:00
parent 0a6176cf53
commit 8e01c7c8ec
99 changed files with 34739 additions and 0 deletions
@@ -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>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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.'
)
File diff suppressed because it is too large Load Diff
@@ -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('&nbsp;', ' ')
html_text = html_text.replace('&amp;', '&')
html_text = html_text.replace('&lt;', '<')
html_text = html_text.replace('&gt;', '>')
html_text = html_text.replace('&quot;', '"')
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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_otk_seo_content_user otk.seo.content.user model_otk_seo_content group_seo_content_user 1 1 1 0
3 access_otk_seo_content_manager otk.seo.content.manager model_otk_seo_content group_seo_content_manager 1 1 1 1
4 access_otk_seo_content_image_user otk.seo.content.image.user model_otk_seo_content_image group_seo_content_user 1 1 1 0
5 access_otk_seo_content_image_manager otk.seo.content.image.manager model_otk_seo_content_image group_seo_content_manager 1 1 1 1
6 access_otk_seo_template_user otk.seo.template.user model_otk_seo_template group_seo_content_user 1 0 0 0
7 access_otk_seo_template_manager otk.seo.template.manager model_otk_seo_template group_seo_content_manager 1 1 1 1
8 access_otk_seo_content_version_user otk.seo.content.version.user model_otk_seo_content_version group_seo_content_user 1 0 0 0
9 access_otk_seo_content_version_manager otk.seo.content.version.manager model_otk_seo_content_version group_seo_content_manager 1 1 1 1
10 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
11 access_otk_seo_brand_voice_user otk.seo.brand.voice.user model_otk_seo_brand_voice group_seo_content_user 1 0 0 0
12 access_otk_seo_brand_voice_manager otk.seo.brand.voice.manager model_otk_seo_brand_voice group_seo_content_manager 1 1 1 1
13 access_otk_seo_content_batch_user otk.seo.content.batch.user model_otk_seo_content_batch group_seo_content_user 1 1 1 0
14 access_otk_seo_content_batch_manager otk.seo.content.batch.manager model_otk_seo_content_batch group_seo_content_manager 1 1 1 1
15 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
16 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
17 access_otk_seo_reference_user otk.seo.reference.user model_otk_seo_reference group_seo_content_user 1 0 0 0
18 access_otk_seo_reference_manager otk.seo.reference.manager model_otk_seo_reference group_seo_content_manager 1 1 1 1
19 access_otk_seo_reference_tag_user otk.seo.reference.tag.user model_otk_seo_reference_tag group_seo_content_user 1 0 0 0
20 access_otk_seo_reference_tag_manager otk.seo.reference.tag.manager model_otk_seo_reference_tag group_seo_content_manager 1 1 1 1
21 access_otk_seo_content_idea_user otk.seo.content.idea.user model_otk_seo_content_idea group_seo_content_user 1 1 1 0
22 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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

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 &amp; 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

Binary file not shown.

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&#10;keyword2, topic2&#10;..."/>
</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 &lt;= 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 &amp; 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 (&lt;50)"
domain="[('seo_score', '&lt;', 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 &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")