Restructure project files to follow the addon layout: - Move models to addons/itsulu_blog_publisher/models/ - Move services (LLM providers, routers) to addons/itsulu_blog_publisher/services/ - Move wizards to addons/itsulu_blog_publisher/wizards/ - Move views (XML templates) to addons/itsulu_blog_publisher/views/ - Move data (cron, mail templates) to addons/itsulu_blog_publisher/data/ - Move security (ACL) to addons/itsulu_blog_publisher/security/ - Move tests and factories to addons/itsulu_blog_publisher/tests/ - Move BDD features to addons/itsulu_blog_publisher/features/ - Create __init__.py files for all Python packages This enables proper Odoo module discovery and import structure. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
150 lines
5 KiB
Python
150 lines
5 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
itsulu.blog.topic — Topic queue for AI blog post generation.
|
|
|
|
Topics come from ITSulu's (or the deploying company's) service portfolio.
|
|
Users curate this list in the backend; the scheduler pops the highest-priority
|
|
pending topic for each generation run.
|
|
"""
|
|
from odoo import api, fields, models
|
|
from odoo.exceptions import ValidationError
|
|
|
|
|
|
class BlogTopic(models.Model):
|
|
_name = 'itsulu.blog.topic'
|
|
_description = 'Blog Publisher — Topic Queue'
|
|
_order = 'priority_order asc, sequence asc, id asc'
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Fields #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
name = fields.Char(
|
|
string='Topic / Title Hint',
|
|
required=True,
|
|
help='What the blog post should be about. Can be a title, a question, '
|
|
'or a brief description. The LLM will elaborate from this.',
|
|
)
|
|
|
|
sequence = fields.Integer(
|
|
string='Sequence',
|
|
default=10,
|
|
help='Lower number = picked first within the same priority level.',
|
|
)
|
|
|
|
priority = fields.Selection(
|
|
selection=[
|
|
('low', 'Low'),
|
|
('normal', 'Normal'),
|
|
('high', 'High'),
|
|
('urgent', 'Urgent'),
|
|
],
|
|
string='Priority',
|
|
default='normal',
|
|
required=True,
|
|
index=True,
|
|
)
|
|
|
|
# Numeric equivalent for clean ordering in _order
|
|
priority_order = fields.Integer(
|
|
string='Priority Order',
|
|
compute='_compute_priority_order',
|
|
store=True,
|
|
help='Computed from priority for clean DB ordering.',
|
|
)
|
|
|
|
state = fields.Selection(
|
|
selection=[
|
|
('pending', 'Pending'),
|
|
('used', 'Used'),
|
|
('skipped', 'Skipped'),
|
|
],
|
|
string='State',
|
|
default='pending',
|
|
required=True,
|
|
index=True,
|
|
)
|
|
|
|
blog_id = fields.Many2one(
|
|
comodel_name='blog.blog',
|
|
string='Target Blog',
|
|
ondelete='set null',
|
|
help='Optional — force this topic to publish to a specific blog. '
|
|
'If blank, the schedule slot\'s configured blog is used.',
|
|
)
|
|
|
|
notes = fields.Text(
|
|
string='Notes / Context',
|
|
help='Additional context for the LLM — e.g. specific services to highlight, '
|
|
'recent news to reference, or a preferred angle.',
|
|
)
|
|
|
|
tone = fields.Char(
|
|
string='Tone Override',
|
|
help='E.g. "technical", "beginner-friendly", "thought leadership". '
|
|
'Overrides the schedule slot\'s default tone.',
|
|
)
|
|
|
|
used_date = fields.Datetime(
|
|
string='Used On',
|
|
readonly=True,
|
|
help='Timestamp when this topic was sent to the LLM.',
|
|
)
|
|
|
|
generation_log_id = fields.Many2one(
|
|
comodel_name='itsulu.blog.generation.log',
|
|
string='Generation Log',
|
|
readonly=True,
|
|
ondelete='set null',
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Computed fields #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@api.depends('priority')
|
|
def _compute_priority_order(self):
|
|
_map = {'urgent': 1, 'high': 2, 'normal': 3, 'low': 4}
|
|
for rec in self:
|
|
rec.priority_order = _map.get(rec.priority, 3)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Constraints #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@api.constrains('name')
|
|
def _check_name_not_empty(self):
|
|
for rec in self:
|
|
if not (rec.name or '').strip():
|
|
raise ValidationError("Topic name cannot be empty.")
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Business logic #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@api.model
|
|
def get_next_topic(self):
|
|
"""
|
|
Return the single highest-priority pending topic, or an empty recordset.
|
|
Priority order: urgent > high > normal > low.
|
|
Within the same priority, sequence ASC then id ASC.
|
|
"""
|
|
return self.search([('state', '=', 'pending')], limit=1)
|
|
|
|
def mark_used(self, generation_log=None):
|
|
"""Set state to 'used' and record the timestamp."""
|
|
self.ensure_one()
|
|
vals = {
|
|
'state': 'used',
|
|
'used_date': fields.Datetime.now(),
|
|
}
|
|
if generation_log:
|
|
vals['generation_log_id'] = generation_log.id
|
|
self.write(vals)
|
|
|
|
def action_mark_pending(self):
|
|
"""Reset a used/skipped topic back to pending for re-use."""
|
|
self.write({'state': 'pending', 'used_date': False, 'generation_log_id': False})
|
|
|
|
def action_mark_skipped(self):
|
|
self.write({'state': 'skipped'})
|