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>
521 lines
18 KiB
Python
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()
|