itsulu-blog-publisher/addons/itsulu_blog_publisher/models/blog_post_social.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

141 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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='1503000 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