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>
141 lines
5.5 KiB
Python
141 lines
5.5 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
itsulu.blog.post.social — Social media copy for one published blog post.
|
||
|
||
One record per blog.post. Stores the AI-generated platform copy so operators
|
||
can review and edit before the notification email is sent, and for future
|
||
reference.
|
||
"""
|
||
from odoo import api, fields, models
|
||
from odoo.exceptions import ValidationError
|
||
|
||
|
||
class BlogPostSocial(models.Model):
|
||
_name = 'itsulu.blog.post.social'
|
||
_description = 'Blog Publisher — Social Media Copy'
|
||
_rec_name = 'blog_post_id'
|
||
|
||
blog_post_id = fields.Many2one(
|
||
comodel_name='blog.post',
|
||
string='Blog Post',
|
||
required=True,
|
||
ondelete='cascade',
|
||
index=True,
|
||
)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# X / Twitter #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
twitter_post_a = fields.Text(
|
||
string='X / Twitter — Post A',
|
||
help='Max 280 characters excluding URL. Hook: statistic or question.',
|
||
)
|
||
twitter_post_b = fields.Text(
|
||
string='X / Twitter — Post B',
|
||
help='Max 280 characters excluding URL. Different angle from Post A.',
|
||
)
|
||
twitter_enabled = fields.Boolean(string='Generate X/Twitter', default=True)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# BlueSky #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
bluesky_post_a = fields.Text(
|
||
string='BlueSky — Post A',
|
||
help='Max 300 characters excluding URL.',
|
||
)
|
||
bluesky_post_b = fields.Text(
|
||
string='BlueSky — Post B',
|
||
help='Max 300 characters excluding URL.',
|
||
)
|
||
bluesky_enabled = fields.Boolean(string='Generate BlueSky', default=True)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Fediverse / Mastodon #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
mastodon_post = fields.Text(
|
||
string='Fediverse / Mastodon',
|
||
help='Max 500 characters excluding URL. Community-oriented tone.',
|
||
)
|
||
mastodon_enabled = fields.Boolean(string='Generate Mastodon', default=True)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# LinkedIn #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
linkedin_post = fields.Text(
|
||
string='LinkedIn',
|
||
help='150–3000 characters. Professional, data-driven. Include insight + CTA.',
|
||
)
|
||
linkedin_enabled = fields.Boolean(string='Generate LinkedIn', default=True)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Sources #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
sources_referenced = fields.Text(
|
||
string='Sources Referenced',
|
||
help='Newline-separated list of "Title — URL" pairs cited by the LLM.',
|
||
)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Constraints #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
_sql_constraints = [
|
||
(
|
||
'blog_post_unique',
|
||
'UNIQUE(blog_post_id)',
|
||
'Each blog post can have only one social media copy record.',
|
||
)
|
||
]
|
||
|
||
@api.constrains('twitter_post_a', 'twitter_post_b')
|
||
def _check_twitter_length(self):
|
||
"""Warn (not error) when Twitter posts exceed 280 characters."""
|
||
for rec in self:
|
||
for field_name in ('twitter_post_a', 'twitter_post_b'):
|
||
value = getattr(rec, field_name) or ''
|
||
# Strip {{URL}} placeholder before counting
|
||
stripped = value.replace('{{URL}}', '').strip()
|
||
if len(stripped) > 280:
|
||
raise ValidationError(
|
||
f"X/Twitter post ({field_name}) is {len(stripped)} characters "
|
||
f"(max 280 excluding URL). Please shorten it."
|
||
)
|
||
|
||
@api.constrains('mastodon_post')
|
||
def _check_mastodon_length(self):
|
||
for rec in self:
|
||
value = (rec.mastodon_post or '').replace('{{URL}}', '').strip()
|
||
if len(value) > 500:
|
||
raise ValidationError(
|
||
f"Mastodon post is {len(value)} characters (max 500 excluding URL)."
|
||
)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Helper #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def substitute_url(self, post_url: str) -> 'BlogPostSocial':
|
||
"""
|
||
Replace {{URL}} placeholder in all social copy fields with the actual URL.
|
||
Called just before sending the notification email.
|
||
Returns self for chaining.
|
||
"""
|
||
self.ensure_one()
|
||
fields_to_update = [
|
||
'twitter_post_a', 'twitter_post_b',
|
||
'bluesky_post_a', 'bluesky_post_b',
|
||
'mastodon_post', 'linkedin_post',
|
||
]
|
||
vals = {}
|
||
for fname in fields_to_update:
|
||
current = getattr(self, fname) or ''
|
||
if '{{URL}}' in current:
|
||
vals[fname] = current.replace('{{URL}}', post_url)
|
||
if vals:
|
||
self.write(vals)
|
||
return self
|