itsulu-blog-publisher/addons/itsulu_blog_publisher/models/blog_topic.py
Nicholas Riegel 0fc4febabf Reorganize codebase into Odoo addon structure per ARCHITECTURE.md
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>
2026-05-29 12:11:42 -04:00

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'})