nordabiz/services/social_publisher_service.py
Maciej Pienczyn 4a033f0d81 feat: Add Social Media Publisher module (MVP)
Admin panel module for publishing posts on NORDA chamber Facebook page.
Includes AI content generation (Gemini), post workflow (draft/approved/
scheduled/published), Facebook Graph API publishing, and engagement tracking.

New: migration 070, SocialPost/SocialMediaConfig models, publisher service,
admin routes with AJAX, 3 templates (list/form/settings).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:08:29 +01:00

521 lines
18 KiB
Python

"""
Social Media Publisher Service
==============================
Business logic for creating, editing, scheduling and publishing social media posts
for the NORDA Business Chamber Facebook page.
"""
import logging
import os
from datetime import datetime
from typing import Optional, Dict, List, Tuple
from database import SessionLocal, SocialPost, SocialMediaConfig, Company, NordaEvent
logger = logging.getLogger(__name__)
# Post types supported
POST_TYPES = {
'member_spotlight': 'Poznaj Członka NORDA',
'event_invitation': 'Zaproszenie na Wydarzenie',
'event_recap': 'Relacja z Wydarzenia',
'regional_news': 'Aktualności Regionalne',
'chamber_news': 'Aktualności Izby',
}
# AI Prompt templates for NORDA chamber posts (Polish)
AI_PROMPTS = {
'member_spotlight': """Napisz post na Facebook dla Izby Gospodarczej NORDA Biznes, przedstawiający firmę członkowską:
Firma: {company_name}
Branża: {category}
Miasto: {city}
Opis: {description}
Strona WWW: {website}
Post powinien:
- Zaczynać się od ciepłego nagłówka typu "Poznajcie naszego członka!" lub podobnego
- Opisać czym zajmuje się firma (2-3 zdania)
- Podkreślić wartość tej firmy dla społeczności biznesowej
- Zachęcić do odwiedzenia strony firmy
- Zawierać 3-5 hashtagów (#NordaBiznes #IzbaGospodarcza #Wejherowo + branżowe)
- Być napisany ciepło, z dumą i wspierająco
- Mieć 100-200 słów
- Być po polsku
Odpowiedz WYŁĄCZNIE tekstem postu (z hashtagami na końcu).""",
'event_invitation': """Napisz post na Facebook zapraszający na wydarzenie Izby Gospodarczej NORDA Biznes:
Wydarzenie: {event_title}
Data: {event_date}
Miejsce: {event_location}
Opis: {event_description}
Post powinien:
- Być entuzjastyczny i zachęcający do udziału
- Zawierać najważniejsze informacje (co, kiedy, gdzie)
- Wspomnieć korzyści z udziału
- Zawierać CTA (zachęta do rejestracji/kontaktu)
- Zawierać 3-5 hashtagów (#NordaBiznes #Networking #Wejherowo + tematyczne)
- Mieć 100-150 słów
- Być po polsku
Odpowiedz WYŁĄCZNIE tekstem postu.""",
'event_recap': """Napisz post na Facebook będący relacją z wydarzenia Izby Gospodarczej NORDA Biznes:
Wydarzenie: {event_title}
Data: {event_date}
Miejsce: {event_location}
Tematy: {event_topics}
Liczba uczestników: {attendees_count}
Post powinien:
- Dziękować uczestnikom za obecność
- Podsumować najważniejsze tematy/wnioski
- Wspomnieć atmosferę i wartość spotkania
- Zachęcić do udziału w kolejnych wydarzeniach
- Zawierać 3-5 hashtagów
- Mieć 100-200 słów
- Być po polsku, relacyjny i dziękujący
Odpowiedz WYŁĄCZNIE tekstem postu.""",
'regional_news': """Napisz post na Facebook dla Izby Gospodarczej NORDA Biznes komentujący aktualność regionalną:
Temat: {topic}
Źródło: {source}
Fakty: {facts}
Post powinien:
- Informować o temacie w kontekście biznesowym regionu
- Być informacyjny i ekspercki
- Dodać perspektywę Izby (jak to wpływa na lokalny biznes)
- Zawierać 3-5 hashtagów (#NordaBiznes #Pomorze #Wejherowo + tematyczne)
- Mieć 100-200 słów
- Być po polsku
Odpowiedz WYŁĄCZNIE tekstem postu.""",
'chamber_news': """Napisz post na Facebook z aktualnościami Izby Gospodarczej NORDA Biznes:
Temat: {topic}
Szczegóły: {details}
Post powinien:
- Być oficjalny ale przystępny
- Przekazać najważniejsze informacje
- Zachęcić do interakcji (pytania, komentarze)
- Zawierać 3-5 hashtagów (#NordaBiznes #IzbaGospodarcza + tematyczne)
- Mieć 80-150 słów
- Być po polsku
Odpowiedz WYŁĄCZNIE tekstem postu.""",
}
class SocialPublisherService:
"""Service for managing social media posts for NORDA chamber."""
# ---- CRUD Operations ----
def get_posts(self, status: str = None, post_type: str = None,
limit: int = 50, offset: int = 0) -> List[SocialPost]:
"""Get posts with optional filters."""
db = SessionLocal()
try:
query = db.query(SocialPost).order_by(SocialPost.created_at.desc())
if status:
query = query.filter(SocialPost.status == status)
if post_type:
query = query.filter(SocialPost.post_type == post_type)
return query.offset(offset).limit(limit).all()
finally:
db.close()
def get_post(self, post_id: int) -> Optional[SocialPost]:
"""Get single post by ID."""
db = SessionLocal()
try:
return db.query(SocialPost).filter(SocialPost.id == post_id).first()
finally:
db.close()
def create_post(self, post_type: str, content: str, user_id: int,
platform: str = 'facebook', hashtags: str = None,
company_id: int = None, event_id: int = None,
image_path: str = None, ai_model: str = None,
ai_prompt_template: str = None) -> SocialPost:
"""Create a new post draft."""
db = SessionLocal()
try:
post = SocialPost(
post_type=post_type,
platform=platform,
content=content,
hashtags=hashtags,
image_path=image_path,
company_id=company_id,
event_id=event_id,
status='draft',
ai_model=ai_model,
ai_prompt_template=ai_prompt_template,
created_by=user_id,
)
db.add(post)
db.commit()
db.refresh(post)
logger.info(f"Created social post #{post.id} type={post_type}")
return post
except Exception as e:
db.rollback()
logger.error(f"Failed to create social post: {e}")
raise
finally:
db.close()
def update_post(self, post_id: int, **kwargs) -> Optional[SocialPost]:
"""Update post fields. Only draft/approved posts can be edited."""
db = SessionLocal()
try:
post = db.query(SocialPost).filter(SocialPost.id == post_id).first()
if not post:
return None
if post.status in ('published', 'failed'):
logger.warning(f"Cannot edit post #{post_id} with status={post.status}")
return None
allowed_fields = {'content', 'hashtags', 'image_path', 'post_type',
'company_id', 'event_id', 'scheduled_at'}
for key, value in kwargs.items():
if key in allowed_fields:
setattr(post, key, value)
post.updated_at = datetime.now()
db.commit()
db.refresh(post)
return post
except Exception as e:
db.rollback()
logger.error(f"Failed to update post #{post_id}: {e}")
raise
finally:
db.close()
def delete_post(self, post_id: int) -> bool:
"""Delete a post (only draft/approved)."""
db = SessionLocal()
try:
post = db.query(SocialPost).filter(SocialPost.id == post_id).first()
if not post:
return False
if post.status == 'published':
logger.warning(f"Cannot delete published post #{post_id}")
return False
db.delete(post)
db.commit()
logger.info(f"Deleted social post #{post_id}")
return True
except Exception as e:
db.rollback()
logger.error(f"Failed to delete post #{post_id}: {e}")
return False
finally:
db.close()
# ---- Workflow ----
def approve_post(self, post_id: int, user_id: int) -> Optional[SocialPost]:
"""Approve a draft post."""
db = SessionLocal()
try:
post = db.query(SocialPost).filter(SocialPost.id == post_id).first()
if not post or post.status != 'draft':
return None
post.status = 'approved'
post.approved_by = user_id
post.updated_at = datetime.now()
db.commit()
db.refresh(post)
logger.info(f"Approved post #{post_id} by user #{user_id}")
return post
finally:
db.close()
def schedule_post(self, post_id: int, scheduled_at: datetime) -> Optional[SocialPost]:
"""Schedule an approved post for future publishing."""
db = SessionLocal()
try:
post = db.query(SocialPost).filter(SocialPost.id == post_id).first()
if not post or post.status not in ('draft', 'approved'):
return None
post.status = 'scheduled'
post.scheduled_at = scheduled_at
post.updated_at = datetime.now()
db.commit()
db.refresh(post)
logger.info(f"Scheduled post #{post_id} for {scheduled_at}")
return post
finally:
db.close()
def publish_post(self, post_id: int) -> Tuple[bool, str]:
"""Publish a post to Facebook immediately.
Returns:
(success: bool, message: str)
"""
db = SessionLocal()
try:
post = db.query(SocialPost).filter(SocialPost.id == post_id).first()
if not post:
return False, "Post nie znaleziony"
if post.status not in ('draft', 'approved', 'scheduled'):
return False, f"Nie można opublikować posta ze statusem: {post.status}"
# Get FB config
config = db.query(SocialMediaConfig).filter(
SocialMediaConfig.platform == 'facebook',
SocialMediaConfig.is_active == True
).first()
if not config or not config.access_token or not config.page_id:
return False, "Facebook nie jest skonfigurowany. Ustaw token w Ustawieniach."
# Build message with hashtags
message = post.content
if post.hashtags:
message += f"\n\n{post.hashtags}"
# Determine if draft mode
published = not config.debug_mode
# Publish via Facebook Graph API
from facebook_graph_service import FacebookGraphService
fb = FacebookGraphService(config.access_token)
image_path = None
if post.image_path:
base_dir = os.path.dirname(os.path.abspath(__file__))
full_path = os.path.join(os.path.dirname(base_dir), post.image_path)
if os.path.exists(full_path):
image_path = full_path
result = fb.create_post(
page_id=config.page_id,
message=message,
image_path=image_path,
published=published,
)
if result and 'id' in result:
post.status = 'published'
post.published_at = datetime.now()
post.meta_post_id = result['id']
post.meta_response = result
post.updated_at = datetime.now()
db.commit()
mode = "DRAFT (debug)" if config.debug_mode else "LIVE"
logger.info(f"Published post #{post_id} -> FB {result['id']} ({mode})")
return True, f"Post opublikowany ({mode}): {result['id']}"
else:
post.status = 'failed'
post.meta_response = result
post.updated_at = datetime.now()
db.commit()
return False, "Publikacja nie powiodła się. Sprawdź konfigurację Facebook."
except Exception as e:
db.rollback()
logger.error(f"Failed to publish post #{post_id}: {e}")
return False, f"Blad publikacji: {str(e)}"
finally:
db.close()
# ---- AI Content Generation ----
def generate_content(self, post_type: str, context: dict) -> Tuple[str, str]:
"""Generate post content using AI.
Args:
post_type: One of POST_TYPES keys
context: Dict with template variables
Returns:
(content: str, ai_model: str)
"""
template = AI_PROMPTS.get(post_type)
if not template:
raise ValueError(f"Unknown post type: {post_type}")
# Fill template with context
try:
prompt = template.format(**context)
except KeyError as e:
raise ValueError(f"Missing context field: {e}")
# Generate with Gemini
from gemini_service import generate_text
result = generate_text(prompt)
if not result:
raise RuntimeError("AI nie wygenerował treści. Spróbuj ponownie.")
return result.strip(), 'gemini-3-flash'
def get_company_context(self, company_id: int) -> dict:
"""Get company data for AI prompt context."""
db = SessionLocal()
try:
company = db.query(Company).filter(Company.id == company_id).first()
if not company:
return {}
return {
'company_name': company.name or '',
'category': company.category or '',
'city': company.city or 'Wejherowo',
'description': company.description or company.short_description or '',
'website': company.website or '',
}
finally:
db.close()
def get_event_context(self, event_id: int) -> dict:
"""Get event data for AI prompt context."""
db = SessionLocal()
try:
event = db.query(NordaEvent).filter(NordaEvent.id == event_id).first()
if not event:
return {}
return {
'event_title': event.title or '',
'event_date': event.event_date.strftime('%d.%m.%Y %H:%M') if event.event_date else '',
'event_location': event.location or '',
'event_description': event.description or '',
}
finally:
db.close()
# ---- Facebook Config ----
def get_fb_config(self) -> Optional[SocialMediaConfig]:
"""Get Facebook configuration."""
db = SessionLocal()
try:
return db.query(SocialMediaConfig).filter(
SocialMediaConfig.platform == 'facebook'
).first()
finally:
db.close()
def save_fb_config(self, page_id: str, page_name: str, access_token: str,
debug_mode: bool, user_id: int) -> SocialMediaConfig:
"""Save/update Facebook configuration."""
db = SessionLocal()
try:
config = db.query(SocialMediaConfig).filter(
SocialMediaConfig.platform == 'facebook'
).first()
if not config:
config = SocialMediaConfig(platform='facebook')
db.add(config)
config.page_id = page_id
config.page_name = page_name
config.access_token = access_token
config.debug_mode = debug_mode
config.is_active = True
config.updated_by = user_id
config.updated_at = datetime.now()
db.commit()
db.refresh(config)
logger.info(f"Saved FB config: page={page_name} debug={debug_mode}")
return config
except Exception as e:
db.rollback()
logger.error(f"Failed to save FB config: {e}")
raise
finally:
db.close()
# ---- Engagement Tracking ----
def refresh_engagement(self, post_id: int) -> Optional[Dict]:
"""Refresh engagement metrics from Facebook for a published post."""
db = SessionLocal()
try:
post = db.query(SocialPost).filter(SocialPost.id == post_id).first()
if not post or not post.meta_post_id:
return None
config = db.query(SocialMediaConfig).filter(
SocialMediaConfig.platform == 'facebook',
SocialMediaConfig.is_active == True
).first()
if not config or not config.access_token:
return None
from facebook_graph_service import FacebookGraphService
fb = FacebookGraphService(config.access_token)
engagement = fb.get_post_engagement(post.meta_post_id)
if engagement:
post.engagement_likes = engagement.get('likes', 0)
post.engagement_comments = engagement.get('comments', 0)
post.engagement_shares = engagement.get('shares', 0)
post.engagement_reach = engagement.get('reactions_total', 0)
post.engagement_updated_at = datetime.now()
post.updated_at = datetime.now()
db.commit()
return engagement
return None
finally:
db.close()
# ---- Stats ----
def get_stats(self) -> Dict:
"""Get summary statistics for social publisher dashboard."""
db = SessionLocal()
try:
from sqlalchemy import func
total = db.query(func.count(SocialPost.id)).scalar() or 0
by_status = dict(
db.query(SocialPost.status, func.count(SocialPost.id))
.group_by(SocialPost.status).all()
)
by_type = dict(
db.query(SocialPost.post_type, func.count(SocialPost.id))
.group_by(SocialPost.post_type).all()
)
# Avg engagement for published posts
avg_likes = db.query(func.avg(SocialPost.engagement_likes)).filter(
SocialPost.status == 'published'
).scalar() or 0
avg_comments = db.query(func.avg(SocialPost.engagement_comments)).filter(
SocialPost.status == 'published'
).scalar() or 0
return {
'total': total,
'by_status': by_status,
'by_type': by_type,
'avg_engagement': {
'likes': round(float(avg_likes), 1),
'comments': round(float(avg_comments), 1),
},
}
finally:
db.close()
# Singleton instance
social_publisher = SocialPublisherService()